Skip to main content

max / goingson

20.5 KB · 504 lines History Blame Raw
1 /**
2 * GoingsOn - Cloud Sync Module
3 * Cloud sync authentication, encryption setup, sync operations, and settings
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9
10 // ============ Cloud Sync ============
11
12 /**
13 * Render the cloud sync settings section into a container element.
14 * Shows the appropriate state (not configured / not authenticated / encryption setup / dashboard).
15 * @param {HTMLElement} container - The settings content container
16 */
17 async function renderSyncSection(container) {
18 let status;
19 try {
20 status = await GoingsOn.api.sync.status();
21 } catch (err) {
22 container.innerHTML = `
23 <div class="settings-section">
24 <h3 class="settings-heading">Cloud Sync</h3>
25 <p class="text-danger">Failed to load sync status: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>
26 </div>
27 `;
28 return;
29 }
30
31 const renderFormField = GoingsOn.ui.renderFormField;
32 let sectionContent;
33
34 if (!status.configured) {
35 sectionContent = `
36 <div class="text-center sync-empty">
37 <p class="text-secondary">Cloud sync is not available in this build.</p>
38 </div>
39 `;
40 } else if (!status.authenticated) {
41 sectionContent = `
42 <div class="text-center sync-empty">
43 <p class="text-secondary sync-empty-message">Connect your Makenot.work account to sync data across devices.</p>
44 <button class="btn btn-primary" onclick="GoingsOn.settings.startSyncAuth()">Connect to Makenot.work</button>
45 </div>
46 `;
47 } else if (!status.encryptionReady) {
48 const isNewDevice = status.hasServerKey === false;
49 const heading = isNewDevice ? 'Set Up Encryption' : 'Enter Encryption Password';
50 const hint = isNewDevice
51 ? 'Choose a password to encrypt your synced data. You will need this password on other devices.'
52 : 'Enter the encryption password you set up on your first device.';
53
54 sectionContent = `
55 <p class="text-secondary sync-hint">${esc(hint)}</p>
56 ${renderFormField({ kind: 'password', name: 'sync-encryption-password', label: 'Encryption Password', placeholder: 'Enter password', required: true })}
57 ${isNewDevice ? renderFormField({ kind: 'password', name: 'sync-encryption-confirm', label: 'Confirm Password', placeholder: 'Confirm password', required: true }) : ''}
58 <div id="sync-encryption-error" class="sync-encryption-error"></div>
59 <div class="sync-section-actions">
60 <button class="btn btn-primary" onclick="GoingsOn.settings.submitEncryption(${isNewDevice})">${esc(heading)}</button>
61 </div>
62 `;
63 } else {
64 const lastSync = status.lastSyncAt
65 ? new Date(status.lastSyncAt).toLocaleString()
66 : 'Never';
67 const intervalOptions = [1, 2, 5, 10, 15, 30, 60];
68
69 // Phase 7 Tier 2 #8 — surface the connected MNW account so the user can
70 // tell which account this device is paired with (matters when juggling
71 // personal vs. work accounts). Failure is non-fatal: sync still works.
72 let accountRow = '';
73 try {
74 const account = await GoingsOn.api.sync.accountInfo();
75 accountRow = `
76 <div class="sync-account-row">
77 <span class="sync-account-label">Signed in as</span>
78 <span class="sync-account-value">${esc(account.email)} <span class="sync-account-username">(${esc(account.username)})</span></span>
79 </div>
80 `;
81 } catch (_) {}
82
83 sectionContent = `
84 <div class="sync-status-row">
85 <span class="sync-status-dot"></span>
86 <span class="sync-status-label">Connected to ${esc(status.serverUrl || 'server')}</span>
87 </div>
88
89 ${accountRow}
90
91 <div class="sync-stats-grid">
92 <div class="sync-stat">
93 <div class="sync-stat-label">Last Sync</div>
94 <div class="sync-stat-value">${esc(lastSync)}</div>
95 </div>
96 <div class="sync-stat">
97 <div class="sync-stat-label">Pending Changes</div>
98 <div class="sync-stat-value">${status.pendingChanges}</div>
99 </div>
100 </div>
101
102 <div class="sync-section-actions">
103 <button class="btn btn-primary" onclick="GoingsOn.settings.doSyncNow()" id="sync-now-btn">Sync Now</button>
104 </div>
105
106 <div class="section-divider">
107 <div class="form-group">
108 <label class="form-checkbox-label">
109 <input type="checkbox" id="sync-auto-enabled" ${status.autoSyncEnabled ? 'checked' : ''}
110 onchange="GoingsOn.settings.updateSyncSettings()">
111 <span>Enable automatic sync</span>
112 </label>
113 </div>
114 ${renderFormField({
115 kind: 'select',
116 name: 'sync-interval',
117 id: 'sync-interval',
118 label: 'Sync Interval',
119 value: status.syncIntervalMinutes,
120 attrs: { onchange: 'GoingsOn.settings.updateSyncSettings()' },
121 options: intervalOptions.map(m => ({
122 value: String(m),
123 label: m === 1 ? '1 minute' : m + ' minutes',
124 selected: status.syncIntervalMinutes === m,
125 })),
126 })}
127 </div>
128
129 <div class="section-divider">
130 <button class="btn btn-danger" onclick="GoingsOn.settings.disconnectSync()">Disconnect</button>
131 </div>
132 `;
133 }
134
135 container.innerHTML = `
136 <div class="settings-section">
137 <h3 class="settings-heading">Cloud Sync</h3>
138 <div id="sync-subscription-banner"></div>
139 ${sectionContent}
140 </div>
141 `;
142
143 // After render: check subscription status and show banner if needed
144 if (status.encryptionReady) {
145 checkSubscriptionBanner();
146 }
147 }
148
149 /**
150 * Check subscription status and show/hide the subscription banner.
151 */
152 async function checkSubscriptionBanner() {
153 const banner = document.getElementById('sync-subscription-banner');
154 if (!banner) return;
155
156 try {
157 const sub = await GoingsOn.api.sync.subscriptionStatus();
158 if (sub.active) {
159 const periodEnd = sub.currentPeriodEnd
160 ? new Date(sub.currentPeriodEnd).toLocaleDateString()
161 : '';
162 banner.innerHTML = `
163 <div class="sync-banner">
164 Sync subscription active${periodEnd ? ' (renews ' + esc(periodEnd) + ')' : ''}
165 </div>
166 `;
167 } else {
168 let tiersHtml = '<p class="sync-banner-tier-line">Loading pricing...</p>';
169 try {
170 // GoingsOn syncs metadata only — no blob storage — so the
171 // cap defaults to the formula's minimum (10 GiB), which
172 // pins the price at the floor (`min_charge_cents`).
173 const pricing = await GoingsOn.api.sync.getTiers();
174 if (pricing && pricing.min_charge_cents != null) {
175 const monthly = '$' + (pricing.min_charge_cents / 100);
176 const annual = '$' + ((pricing.min_charge_cents * pricing.annual_multiplier) / 100);
177 const monthlyCost = (pricing.min_charge_cents / 100) * 12;
178 const savings = monthlyCost - ((pricing.min_charge_cents * pricing.annual_multiplier) / 100);
179 tiersHtml = `
180 <p class="sync-banner-tier-line">
181 <strong>${esc(annual)}/year</strong>${savings > 0 ? ' (saves $' + savings + ' vs monthly)' : ''} or ${esc(monthly)}/month.
182 Annual saves you money because Stripe charges a fixed fee per transaction.
183 </p>
184 <div class="sync-banner-actions">
185 <button class="btn btn-primary" onclick="GoingsOn.settings.subscribeSyncAnnual()">Subscribe (${esc(annual)}/year)</button>
186 <button class="btn btn-secondary" onclick="GoingsOn.settings.subscribeSyncMonthly()">${esc(monthly)}/month</button>
187 </div>
188 `;
189 }
190 } catch (_) { /* fall through with loading text */ }
191 banner.innerHTML = `
192 <div class="sync-banner sync-banner--warning">
193 <p class="sync-banner-title">Subscription required</p>
194 <p class="sync-banner-body">
195 Cloud sync keeps your tasks, events, contacts, and email settings in sync across devices with end-to-end encryption.
196 </p>
197 ${tiersHtml}
198 </div>
199 `;
200 }
201 } catch (err) {
202 // Non-fatal — just hide the banner
203 banner.innerHTML = '';
204 }
205 }
206
207 /**
208 * Re-render the sync section into the current settings content container.
209 */
210 async function refreshSyncSection() {
211 const container = document.getElementById('settings-content');
212 if (container && GoingsOn.state.currentView === 'settings') {
213 await renderSyncSection(container);
214 }
215 }
216
217 async function startSyncAuth() {
218 try {
219 GoingsOn.ui.showToast('Starting authentication...', 'info');
220 const authData = await GoingsOn.api.sync.startAuth();
221
222 // Open browser
223 if (window.__TAURI__?.shell?.open) {
224 await window.__TAURI__.shell.open(authData.authUrl);
225 } else {
226 window.open(authData.authUrl, '_blank');
227 }
228
229 // Poll for callback result
230 pollSyncAuthResult(authData.port, authData.state);
231 } catch (err) {
232 GoingsOn.ui.showToast('Failed to start auth: ' + GoingsOn.utils.getErrorMessage(err), 'error');
233 }
234 }
235
236 function pollSyncAuthResult(port, expectedState) {
237 let attempts = 0;
238 const maxAttempts = 300; // 5 minutes at 1s intervals
239
240 const pollInterval = setInterval(async () => {
241 attempts++;
242 if (attempts > maxAttempts) {
243 clearInterval(pollInterval);
244 GoingsOn.ui.showToast('Authentication timed out', 'error');
245 return;
246 }
247
248 try {
249 const resp = await fetch(`http://127.0.0.1:${port}/result`);
250 if (resp.status === 200) {
251 const data = await resp.json();
252
253 // Still waiting for the browser redirect
254 if (data.status === 'pending') return;
255
256 clearInterval(pollInterval);
257
258 if (data.code) {
259 // Complete auth
260 try {
261 await GoingsOn.api.sync.completeAuth({
262 code: data.code,
263 state: data.state,
264 });
265 GoingsOn.ui.showToast('Connected to Makenot.work!');
266 refreshSyncIndicator();
267 // Force re-render: open settings overlay and show sync section.
268 await GoingsOn.settings.open();
269 await GoingsOn.settings.showSection('sync');
270 } catch (err) {
271 GoingsOn.ui.showToast('Auth failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
272 }
273 } else if (data.error) {
274 GoingsOn.ui.showToast('Auth error: ' + data.error, 'error');
275 }
276 }
277 // 204 = still waiting, continue polling
278 } catch (_) {
279 // Server not ready yet or gone, keep polling
280 }
281 }, 1000);
282 }
283
284 /**
285 * Submit the encryption password form (new setup or existing unlock).
286 * @param {boolean} isNew - true if setting up encryption for the first time
287 */
288 async function submitEncryption(isNew) {
289 const password = document.getElementById('sync-encryption-password')?.value;
290 const confirm = document.getElementById('sync-encryption-confirm')?.value;
291 const errorDiv = document.getElementById('sync-encryption-error');
292
293 if (!password) {
294 if (errorDiv) {
295 errorDiv.textContent = 'Password is required';
296 errorDiv.classList.add('visible');
297 }
298 return;
299 }
300
301 if (isNew && password !== confirm) {
302 if (errorDiv) {
303 errorDiv.textContent = 'Passwords do not match';
304 errorDiv.classList.add('visible');
305 }
306 return;
307 }
308
309 try {
310 if (isNew) {
311 await GoingsOn.api.sync.setupEncryptionNew(password);
312 } else {
313 await GoingsOn.api.sync.setupEncryptionExisting(password);
314 }
315 GoingsOn.ui.showToast('Encryption configured!');
316 refreshSyncIndicator();
317 refreshSyncSection();
318 } catch (err) {
319 if (errorDiv) {
320 errorDiv.textContent = GoingsOn.utils.getErrorMessage(err);
321 errorDiv.classList.add('visible');
322 }
323 }
324 }
325
326 async function doSyncNow() {
327 const btn = document.getElementById('sync-now-btn');
328 if (btn) {
329 btn.disabled = true;
330 btn.textContent = 'Syncing...';
331 }
332
333 try {
334 const result = await GoingsOn.api.sync.syncNow();
335 GoingsOn.ui.showToast(`Synced: ${result.pushed} pushed, ${result.pulled} pulled`);
336 refreshSyncSection();
337 } catch (err) {
338 GoingsOn.ui.showToast('Sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', {
339 action: { label: 'Retry', fn: syncNow },
340 duration: 8000,
341 });
342 if (btn) {
343 btn.disabled = false;
344 btn.textContent = 'Sync Now';
345 }
346 }
347 }
348
349 async function updateSyncSettings() {
350 const enabled = document.getElementById('sync-auto-enabled')?.checked;
351 const interval = parseInt(document.getElementById('sync-interval')?.value, 10);
352
353 try {
354 await GoingsOn.api.sync.updateSettings({
355 autoSyncEnabled: enabled,
356 syncIntervalMinutes: interval,
357 });
358 } catch (err) {
359 GoingsOn.ui.showToast('Failed to save sync settings: ' + GoingsOn.utils.getErrorMessage(err), 'error');
360 }
361 }
362
363 async function disconnectSync() {
364 const confirmed = await GoingsOn.ui.confirmDelete(
365 'Disconnect Sync',
366 'This will disconnect from the sync service. Your local data will not be deleted. You can reconnect later.'
367 );
368 if (!confirmed) return;
369
370 try {
371 await GoingsOn.api.sync.disconnect();
372 GoingsOn.ui.showToast('Disconnected from sync');
373 refreshSyncIndicator();
374 refreshSyncSection();
375 } catch (err) {
376 GoingsOn.ui.showToast('Failed to disconnect: ' + GoingsOn.utils.getErrorMessage(err), 'error');
377 }
378 }
379
380 // ============ Sync Status Indicator ============
381
382 /**
383 * Format an ISO timestamp as a short relative-time string.
384 * "just now" / "Nm ago" / "Nh ago" / "Mon Jan 14".
385 */
386 function _formatSyncAgo(iso) {
387 if (!iso) return null;
388 const t = new Date(iso).getTime();
389 if (!t) return null;
390 const deltaSec = Math.max(0, Math.floor((Date.now() - t) / 1000));
391 if (deltaSec < 45) return 'just now';
392 if (deltaSec < 60 * 60) return `${Math.floor(deltaSec / 60)}m ago`;
393 if (deltaSec < 60 * 60 * 24) return `${Math.floor(deltaSec / 3600)}h ago`;
394 return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
395 }
396
397 /**
398 * Update the sync status indicator (dot + label + title) in the header bar.
399 * Charter Phase 7 Tier 2 #6: every state must pair color with a non-color signal.
400 */
401 async function refreshSyncIndicator() {
402 const indicator = document.getElementById('sync-indicator');
403 const dot = document.getElementById('sync-dot');
404 const label = document.getElementById('sync-label');
405 if (!indicator || !dot) return;
406
407 try {
408 const status = await GoingsOn.api.sync.status();
409 if (!status.configured) {
410 indicator.classList.add('hidden');
411 return;
412 }
413 indicator.classList.remove('hidden');
414 dot.className = 'sync-dot';
415
416 let labelText;
417 let titleText;
418 if (!status.authenticated) {
419 dot.classList.add('warn');
420 labelText = 'Connect sync';
421 titleText = 'Cloud Sync — sign in to start syncing';
422 } else if (!status.encryptionReady) {
423 dot.classList.add('warn');
424 labelText = 'Set up encryption';
425 titleText = 'Cloud Sync — encryption setup needed';
426 } else {
427 dot.classList.add('connected');
428 const ago = _formatSyncAgo(status.lastSyncAt);
429 labelText = ago ? `Synced ${ago}` : 'Sync ready';
430 titleText = ago
431 ? `Cloud Sync — last sync ${ago}${status.pendingChanges ? ` · ${status.pendingChanges} pending` : ''}`
432 : 'Cloud Sync — ready';
433 }
434 if (label) label.textContent = labelText;
435 indicator.setAttribute('title', titleText);
436 indicator.setAttribute('aria-label', titleText);
437 } catch (_) {
438 indicator.classList.add('hidden');
439 }
440 }
441
442 /**
443 * Subscribe to cloud sync with the given interval.
444 * Opens the Stripe checkout page in the user's browser.
445 */
446 async function subscribeSync(interval) {
447 try {
448 await GoingsOn.api.sync.subscribe(interval);
449 GoingsOn.ui.showToast('Opening checkout in your browser. Complete payment, then return here.');
450 pollForSubscription();
451 } catch (err) {
452 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err), 'error');
453 }
454 }
455
456 /**
457 * Poll subscription status after checkout opens.
458 * Checks every 5s for up to 10 minutes.
459 */
460 function pollForSubscription() {
461 let attempts = 0;
462 const maxAttempts = 120; // 10 minutes at 5s intervals
463
464 const timer = setInterval(async () => {
465 attempts++;
466 if (attempts >= maxAttempts) {
467 clearInterval(timer);
468 return;
469 }
470
471 try {
472 const sub = await GoingsOn.api.sync.subscriptionStatus();
473 if (sub.active) {
474 clearInterval(timer);
475 GoingsOn.ui.showToast('Subscription activated! Sync is now enabled.', 'success');
476 refreshSyncSection();
477 }
478 } catch (_) {
479 // Ignore polling errors
480 }
481 }, 5000);
482 }
483
484 // ============ Populate GoingsOn Namespace ============
485
486 Object.assign(GoingsOn.settings, {
487 openCloudSync: function() {
488 GoingsOn.settings.open();
489 // Small delay to ensure settings view is active before switching section
490 setTimeout(() => GoingsOn.settings.showSection('sync'), 50);
491 },
492 renderSyncSection,
493 startSyncAuth,
494 submitEncryption,
495 doSyncNow,
496 updateSyncSettings,
497 disconnectSync,
498 refreshSyncIndicator,
499 subscribeSyncAnnual: () => subscribeSync('annual'),
500 subscribeSyncMonthly: () => subscribeSync('monthly'),
501 });
502
503 })();
504