/** * GoingsOn - Cloud Sync Module * Cloud sync authentication, encryption setup, sync operations, and settings */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; // ============ Cloud Sync ============ /** * Render the cloud sync settings section into a container element. * Shows the appropriate state (not configured / not authenticated / encryption setup / dashboard). * @param {HTMLElement} container - The settings content container */ async function renderSyncSection(container) { let status; try { status = await GoingsOn.api.sync.status(); } catch (err) { container.innerHTML = `

Cloud Sync

Failed to load sync status: ${esc(GoingsOn.utils.getErrorMessage(err))}

`; return; } const renderFormField = GoingsOn.ui.renderFormField; let sectionContent; if (!status.configured) { sectionContent = `

Cloud sync is not available in this build.

`; } else if (!status.authenticated) { sectionContent = `

Connect your Makenot.work account to sync data across devices.

`; } else if (!status.encryptionReady) { const isNewDevice = status.hasServerKey === false; const heading = isNewDevice ? 'Set Up Encryption' : 'Enter Encryption Password'; const hint = isNewDevice ? 'Choose a password to encrypt your synced data. You will need this password on other devices.' : 'Enter the encryption password you set up on your first device.'; sectionContent = `

${esc(hint)}

${renderFormField({ kind: 'password', name: 'sync-encryption-password', label: 'Encryption Password', placeholder: 'Enter password', required: true })} ${isNewDevice ? renderFormField({ kind: 'password', name: 'sync-encryption-confirm', label: 'Confirm Password', placeholder: 'Confirm password', required: true }) : ''}
`; } else { const lastSync = status.lastSyncAt ? new Date(status.lastSyncAt).toLocaleString() : 'Never'; const intervalOptions = [1, 2, 5, 10, 15, 30, 60]; // Phase 7 Tier 2 #8 — surface the connected MNW account so the user can // tell which account this device is paired with (matters when juggling // personal vs. work accounts). Failure is non-fatal: sync still works. let accountRow = ''; try { const account = await GoingsOn.api.sync.accountInfo(); accountRow = `
Signed in as ${esc(account.email)}
`; } catch (_) {} sectionContent = `
Connected to ${esc(status.serverUrl || 'server')}
${accountRow}
Last Sync
${esc(lastSync)}
Pending Changes
${status.pendingChanges}
${renderFormField({ kind: 'select', name: 'sync-interval', id: 'sync-interval', label: 'Sync Interval', value: status.syncIntervalMinutes, attrs: { onchange: 'GoingsOn.settings.updateSyncSettings()' }, options: intervalOptions.map(m => ({ value: String(m), label: m === 1 ? '1 minute' : m + ' minutes', selected: status.syncIntervalMinutes === m, })), })}
`; } container.innerHTML = `

Cloud Sync

${sectionContent}
`; // After render: check subscription status and show banner if needed if (status.encryptionReady) { checkSubscriptionBanner(); } } /** * Check subscription status and show/hide the subscription banner. */ async function checkSubscriptionBanner() { const banner = document.getElementById('sync-subscription-banner'); if (!banner) return; try { const sub = await GoingsOn.api.sync.subscriptionStatus(); if (sub.active) { const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd).toLocaleDateString() : ''; banner.innerHTML = `
Sync subscription active${periodEnd ? ' (renews ' + esc(periodEnd) + ')' : ''}
`; } else { let tiersHtml = '

Loading pricing...

'; try { // GoingsOn syncs metadata only — no blob storage — so the // cap defaults to the formula's minimum (10 GiB), which // pins the price at the floor (`min_charge_cents`). const pricing = await GoingsOn.api.sync.getTiers(); if (pricing && pricing.min_charge_cents != null) { const monthly = '$' + (pricing.min_charge_cents / 100); const annual = '$' + ((pricing.min_charge_cents * pricing.annual_multiplier) / 100); const monthlyCost = (pricing.min_charge_cents / 100) * 12; const savings = monthlyCost - ((pricing.min_charge_cents * pricing.annual_multiplier) / 100); tiersHtml = `

${esc(annual)}/year${savings > 0 ? ' (saves $' + savings + ' vs monthly)' : ''} or ${esc(monthly)}/month. Annual saves you money because Stripe charges a fixed fee per transaction.

`; } } catch (_) { /* fall through with loading text */ } banner.innerHTML = `

Subscription required

Cloud sync keeps your tasks, events, contacts, and email settings in sync across devices with end-to-end encryption.

${tiersHtml}
`; } } catch (err) { // Non-fatal — just hide the banner banner.innerHTML = ''; } } /** * Re-render the sync section into the current settings content container. */ async function refreshSyncSection() { const container = document.getElementById('settings-content'); if (container && GoingsOn.state.currentView === 'settings') { await renderSyncSection(container); } } async function startSyncAuth() { try { GoingsOn.ui.showToast('Starting authentication...', 'info'); const authData = await GoingsOn.api.sync.startAuth(); // Open browser if (window.__TAURI__?.shell?.open) { await window.__TAURI__.shell.open(authData.authUrl); } else { window.open(authData.authUrl, '_blank'); } // Poll for callback result pollSyncAuthResult(authData.port, authData.state); } catch (err) { GoingsOn.ui.showToast('Failed to start auth: ' + GoingsOn.utils.getErrorMessage(err), 'error'); } } function pollSyncAuthResult(port, expectedState) { let attempts = 0; const maxAttempts = 300; // 5 minutes at 1s intervals const pollInterval = setInterval(async () => { attempts++; if (attempts > maxAttempts) { clearInterval(pollInterval); GoingsOn.ui.showToast('Authentication timed out', 'error'); return; } try { const resp = await fetch(`http://127.0.0.1:${port}/result`); if (resp.status === 200) { const data = await resp.json(); // Still waiting for the browser redirect if (data.status === 'pending') return; clearInterval(pollInterval); if (data.code) { // Complete auth try { await GoingsOn.api.sync.completeAuth({ code: data.code, state: data.state, }); GoingsOn.ui.showToast('Connected to Makenot.work!'); refreshSyncIndicator(); // Force re-render: open settings overlay and show sync section. await GoingsOn.settings.open(); await GoingsOn.settings.showSection('sync'); } catch (err) { GoingsOn.ui.showToast('Auth failed: ' + GoingsOn.utils.getErrorMessage(err), 'error'); } } else if (data.error) { GoingsOn.ui.showToast('Auth error: ' + data.error, 'error'); } } // 204 = still waiting, continue polling } catch (_) { // Server not ready yet or gone, keep polling } }, 1000); } /** * Submit the encryption password form (new setup or existing unlock). * @param {boolean} isNew - true if setting up encryption for the first time */ async function submitEncryption(isNew) { const password = document.getElementById('sync-encryption-password')?.value; const confirm = document.getElementById('sync-encryption-confirm')?.value; const errorDiv = document.getElementById('sync-encryption-error'); if (!password) { if (errorDiv) { errorDiv.textContent = 'Password is required'; errorDiv.classList.add('visible'); } return; } if (isNew && password !== confirm) { if (errorDiv) { errorDiv.textContent = 'Passwords do not match'; errorDiv.classList.add('visible'); } return; } try { if (isNew) { await GoingsOn.api.sync.setupEncryptionNew(password); } else { await GoingsOn.api.sync.setupEncryptionExisting(password); } GoingsOn.ui.showToast('Encryption configured!'); refreshSyncIndicator(); refreshSyncSection(); } catch (err) { if (errorDiv) { errorDiv.textContent = GoingsOn.utils.getErrorMessage(err); errorDiv.classList.add('visible'); } } } async function doSyncNow() { const btn = document.getElementById('sync-now-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; } try { const result = await GoingsOn.api.sync.syncNow(); GoingsOn.ui.showToast(`Synced: ${result.pushed} pushed, ${result.pulled} pulled`); refreshSyncSection(); } catch (err) { GoingsOn.ui.showToast('Sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', { action: { label: 'Retry', fn: syncNow }, duration: 8000, }); if (btn) { btn.disabled = false; btn.textContent = 'Sync Now'; } } } async function updateSyncSettings() { const enabled = document.getElementById('sync-auto-enabled')?.checked; const interval = parseInt(document.getElementById('sync-interval')?.value, 10); try { await GoingsOn.api.sync.updateSettings({ autoSyncEnabled: enabled, syncIntervalMinutes: interval, }); } catch (err) { GoingsOn.ui.showToast('Failed to save sync settings: ' + GoingsOn.utils.getErrorMessage(err), 'error'); } } async function disconnectSync() { const confirmed = await GoingsOn.ui.confirmDelete( 'Disconnect Sync', 'This will disconnect from the sync service. Your local data will not be deleted. You can reconnect later.' ); if (!confirmed) return; try { await GoingsOn.api.sync.disconnect(); GoingsOn.ui.showToast('Disconnected from sync'); refreshSyncIndicator(); refreshSyncSection(); } catch (err) { GoingsOn.ui.showToast('Failed to disconnect: ' + GoingsOn.utils.getErrorMessage(err), 'error'); } } // ============ Sync Status Indicator ============ /** * Format an ISO timestamp as a short relative-time string. * "just now" / "Nm ago" / "Nh ago" / "Mon Jan 14". */ function _formatSyncAgo(iso) { if (!iso) return null; const t = new Date(iso).getTime(); if (!t) return null; const deltaSec = Math.max(0, Math.floor((Date.now() - t) / 1000)); if (deltaSec < 45) return 'just now'; if (deltaSec < 60 * 60) return `${Math.floor(deltaSec / 60)}m ago`; if (deltaSec < 60 * 60 * 24) return `${Math.floor(deltaSec / 3600)}h ago`; return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } /** * Update the sync status indicator (dot + label + title) in the header bar. * Charter Phase 7 Tier 2 #6: every state must pair color with a non-color signal. */ async function refreshSyncIndicator() { const indicator = document.getElementById('sync-indicator'); const dot = document.getElementById('sync-dot'); const label = document.getElementById('sync-label'); if (!indicator || !dot) return; try { const status = await GoingsOn.api.sync.status(); if (!status.configured) { indicator.classList.add('hidden'); return; } indicator.classList.remove('hidden'); dot.className = 'sync-dot'; let labelText; let titleText; if (!status.authenticated) { dot.classList.add('warn'); labelText = 'Connect sync'; titleText = 'Cloud Sync — sign in to start syncing'; } else if (!status.encryptionReady) { dot.classList.add('warn'); labelText = 'Set up encryption'; titleText = 'Cloud Sync — encryption setup needed'; } else { dot.classList.add('connected'); const ago = _formatSyncAgo(status.lastSyncAt); labelText = ago ? `Synced ${ago}` : 'Sync ready'; titleText = ago ? `Cloud Sync — last sync ${ago}${status.pendingChanges ? ` · ${status.pendingChanges} pending` : ''}` : 'Cloud Sync — ready'; } if (label) label.textContent = labelText; indicator.setAttribute('title', titleText); indicator.setAttribute('aria-label', titleText); } catch (_) { indicator.classList.add('hidden'); } } /** * Subscribe to cloud sync with the given interval. * Opens the Stripe checkout page in the user's browser. */ async function subscribeSync(interval) { try { await GoingsOn.api.sync.subscribe(interval); GoingsOn.ui.showToast('Opening checkout in your browser. Complete payment, then return here.'); pollForSubscription(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err), 'error'); } } /** * Poll subscription status after checkout opens. * Checks every 5s for up to 10 minutes. */ function pollForSubscription() { let attempts = 0; const maxAttempts = 120; // 10 minutes at 5s intervals const timer = setInterval(async () => { attempts++; if (attempts >= maxAttempts) { clearInterval(timer); return; } try { const sub = await GoingsOn.api.sync.subscriptionStatus(); if (sub.active) { clearInterval(timer); GoingsOn.ui.showToast('Subscription activated! Sync is now enabled.', 'success'); refreshSyncSection(); } } catch (_) { // Ignore polling errors } }, 5000); } // ============ Populate GoingsOn Namespace ============ Object.assign(GoingsOn.settings, { openCloudSync: function() { GoingsOn.settings.open(); // Small delay to ensure settings view is active before switching section setTimeout(() => GoingsOn.settings.showSection('sync'), 50); }, renderSyncSection, startSyncAuth, submitEncryption, doSyncNow, updateSyncSettings, disconnectSync, refreshSyncIndicator, subscribeSyncAnnual: () => subscribeSync('annual'), subscribeSyncMonthly: () => subscribeSync('monthly'), }); })();