/**
* 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 = `
`;
} 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)} (${esc(account.username)})
`;
// 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 = `
';
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'),
});
})();