Skip to main content

max / goingson

Add sync subscription gate and UI Subscription status command + subscribe command (opens Stripe checkout in browser). Scheduler detects 402 and backs off 1 hour. Settings panel shows subscription banner with $15/year and $2/month options. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 03:16 UTC
Commit: 9f94745d0d415655bc84915c40697806739f39ec
Parent: c353834
6 files changed, +324 insertions, -124 deletions
M docs/todo/todo.md +67 -17
@@ -2,7 +2,7 @@
2 2
3 3 Done: Phases 1-9, Phase 5 attachments, code fuzz, email compose (10/10). Next: sync monetization, live sync, desktop distribution.
4 4
5 - v0.3.1. Audit grade A. ~313 tests. Code fuzz: 18/18 resolved. Migrations 041-044.
5 + v0.3.1. Audit grade A. ~313 tests. Code fuzz: 18/18 resolved. Migrations 041-044. Rust 2024 edition (2026-05-06). rand 0.9.
6 6
7 7 Completed items: [todo_done.md](./todo_done.md)
8 8
@@ -19,10 +19,11 @@ Completed items: [todo_done.md](./todo_done.md)
19 19
20 20 GO is free. Cloud sync is the only revenue source. Includes full metadata + attachment blob sync. See `MNW/server/docs/internal/business/app_sync_pricing.md` for full pricing rationale.
21 21
22 - - [ ] Stripe product + prices: $2/mo monthly, $15/yr annual
23 - - [ ] Sync gate: check subscription status before enabling SyncKit sync
24 - - [ ] Subscription UI: in-app purchase/manage flow (settings panel)
22 + - [x] Stripe pricing: inline price_data ($2/mo, $15/yr), no pre-created products needed
23 + - [x] Sync gate: server returns 402, scheduler backs off 1 hour + emits `sync:subscription-required`
24 + - [x] Subscription UI: banner in sync settings with Annual/Monthly buttons, opens Stripe checkout in browser
25 25 - [ ] Annual billing messaging: explain why annual is preferred (Stripe fee transparency)
26 + - [ ] Test full checkout flow against live Stripe (end-to-end: subscribe → webhook → sync gate passes)
26 27
27 28 ---
28 29
@@ -88,8 +89,8 @@ GO is free. Cloud sync is the only revenue source. Includes full metadata + atta
88 89
89 90 ## Phase 9: UX Polish — 9D Tab Order
90 91
91 - - [ ] Review whether tab order matches actual usage frequency
92 - - [ ] If needed, reorder tabs in `index.html` to match usage
92 + - [x] Review whether tab order matches actual usage frequency — confirmed Work > Time > Messages is correct (2026-05-06)
93 + - [x] If needed, reorder tabs in `index.html` to match usage — no change needed
93 94
94 95 ---
95 96
@@ -112,26 +113,75 @@ GO is free. Cloud sync is the only revenue source. Includes full metadata + atta
112 113
113 114 ---
114 115
115 - ## Usability Audit Remaining (2026-05-02)
116 + ## Usability Audit Remaining
117 +
118 + ### Completed (2026-05-06, UX quick wins batch)
119 + - [x] Add global search / command palette (Cmd+K) — search.js IIFE module (2026-05-05)
120 + - [x] Add Search button in header — opens command palette, visible entry point for Cmd+K
121 + - [x] Welcome modal button 3 now opens email accounts modal (was broken — opened Settings which has no email section)
122 + - [x] `go-welcomed` localStorage only set on button click, not on modal open — re-shows if accidentally dismissed
123 + - [x] Email empty state: shows "Add Account" when no accounts exist, "Compose" when accounts exist (was always "Compose")
124 + - [x] Fixed stray backtick in emails.js empty state code
125 + - [x] Renamed Time tab pills: Day → Day Plan, Week → Weekly Review, Month → Monthly Review (navigation uses data-subview, labels were ambiguous)
126 + - [x] Renamed "Add Annotation" → "Add Note" in task action modal and context menu
127 + - [x] `n` shortcut now works on Contacts view (was missing from `newItemForCurrentView`)
128 + - [x] Promoted "Create Task" to top-level button in email reader (was buried in Actions dropdown)
129 + - [x] Paint-to-create on day plan defaults to "Link to Task" when unscheduled tasks exist (was always "Event")
130 + - [x] App subtitle changed from "Project Management" to "Personal Productivity"
131 + - [x] Recurring events, month/week grid views, Plan/Review dashboards, habit tracking, richer recurrence rules, bulk ops, daily review notes (all completed earlier, 2026-05-06)
132 +
133 + ### Completed (2026-05-06, overflow menus + settings page)
134 + - [x] Three-dot overflow menus on email rows, event rows, project cards — shared `.kebab-btn` CSS, context menus now discoverable without right-click
135 + - [x] Settings page replaces settings modal — sidebar navigation with 6 sections, inline Cloud Sync + Plugins + Backup settings, no more close-modal-open-modal pattern, mobile responsive
136 +
137 + ### Completed (2026-05-06, email compose UX)
138 + - [x] Email recipient highlighting — mirror overlay div with per-address status colors (red=malformed, blue=contact, green=verified sender, default=valid). Batch validation via `validate_email_addresses` Tauri command. Session-scoped cache with 250ms debounce
139 + - [x] Ghost text autocomplete — Tab-to-accept completion from contacts, two-pass ranking (explicit > implicit)
140 + - [x] Implicit contacts — auto-created on send for unknown recipients. Not shown in contacts UI, appear in autocomplete. Post-send toast prompts "Save as contact?" with promote action. Migration 048
141 + - [x] Tiered autocomplete ranking — explicit contacts ranked above implicit in both dropdown and ghost text
116 142
117 143 ### Discoverability
118 144 - [ ] Add touch gesture hints on first mobile use (long-press, swipe, pull-to-refresh)
119 - - [ ] Add global search / command palette (Cmd+K) — backend FTS5 exists but has no UI; search across tasks, projects, emails, contacts, events
145 + - [ ] Add "What's New" dialog after OTA updates — surface CHANGELOG.md entries in-app
146 + - [ ] Command palette: show inline suggestions when typing `is:`, `tag:`, `type:`, `in:` prefixes
147 + - [ ] Quick Add syntax: add "or use Quick Add syntax" link inside the standard New Task form
148 +
149 + ### Learnability
150 + - [x] Email empty state when no accounts: detect zero accounts and show "Add Account" CTA (2026-05-06)
151 + - [ ] Add "Getting Started" item under Help menu to re-trigger welcome tour
152 + - [ ] After email-to-task conversion, offer "Start Timer" in the success toast — eliminates 5-step view switch
153 + - [x] Replace Settings modal with a Settings page (sidebar/tabs for sections) — eliminates modal-closes-then-reopens pattern (2026-05-06)
120 154
121 155 ### Complexity
122 - - [ ] Add batch project linking — let users select multiple tasks via checkboxes and link them all to a project in one action
156 + - [ ] Add batch project linking — select multiple tasks and link them all to a project in one action
157 + - [ ] Contact form: apply "More options" fold pattern (show Name + Notes, fold Nickname/Company/Title/Tags/Birthday/Timezone)
158 + - [ ] Contact bulk tag: replace `window.prompt()` with proper modal using tag autocomplete
159 + - [ ] Backup settings: simplify to On/Off toggle with sensible defaults, "Customize" link for frequency/retention
123 160
124 161 ### Feature Completeness
125 - - [x] Recurring events — virtual expansion (store once, compute instances at query time). Events form now has recurrence field. Expansion integrated into list_events, day planner, weekly review.
126 - - [ ] Calendar month/week grid view — even a basic one (events currently list-only)
127 - - [ ] Separate planning and visualization dashboards — day/week/month views should be distinct from weekly review; weekly review timeline is useful but planning and reviewing are different workflows
128 - - [ ] Habit tracking — daily checklist with streak/completion visualization (e.g. "gym 6/7 days", "music 5/7 days"). Recurring tasks exist but there's no "did I do this every day this week" view. Key for routine-building use cases.
129 - - [x] Richer recurrence rules — RecurrenceRule struct with interval, weekday selection (Mon/Wed/Fri), monthly day-of-month or Nth-weekday (2nd Friday, last Monday). Config UI in both task and event forms. Tasks use rich rules for next-due calculation on completion.
162 + - [ ] Calendar sync — CalDAV / Google Calendar / Outlook integration via plugin system (see Phase 7)
163 + - [x] Plain text email polish — `format=flowed` (RFC 3676) for proper paragraph reflow, `-- \n` signature separator (RFC 2646), clean `>` quoting on replies (2026-05-06)
164 + - [x] Email recipient highlighting + ghost text autocomplete + implicit contacts (2026-05-06)
165 + - [x] Contact dashboard — full-page view with header card, info section, activity timeline (tasks/events/emails sorted chronologically), summary stats. Replaces detail modal. Implicit contacts show "Save as Contact" promote button. (2026-05-06) — aggregate emails, tasks, events linked to a contact into a timeline view
166 + - [ ] Paginate beyond 500-item caps — tasks and emails silently cap at 500; add "load more" or infinite scroll
167 + - [ ] Email sync progress indicator — show count/progress during first sync or full re-sync
168 + - [ ] Contact duplicate detection — check by email address before creating, offer merge on import
130 169 - [ ] Time tracking reports — per-project breakdown, estimated-vs-actual, weekly/monthly summaries
131 - - [ ] Contacts export to vCard — import exists but no export (asymmetric)
132 - - [ ] Bulk operations for contacts (tag, delete) and events (delete)
133 - - [ ] Daily review notes: persist to SQLite + sync (currently localStorage only, lost on reinstall)
170 + - [ ] Contacts export to vCard — import exists but no export (asymmetric) — deferred, not essential for beta
134 171 - [ ] Workload guardrails in day planner — warn when scheduled hours exceed target
172 + - [ ] Mobile compose: add CC/BCC fields (desktop compose has them, mobile modal does not)
173 + - [ ] Mobile search: add search entry point in mobile nav (Cmd+K has no equivalent on touch)
174 +
175 + ---
176 +
177 + ## Phase 10: Notifications & Reminders
178 +
179 + All notification/reminder work bundled into a single phase — shared infrastructure (OS notification API, scheduler, settings UI).
180 +
181 + - [ ] Event reminders — OS notifications before events (5/10/15/30 min configurable). Snooze watcher already handles scheduled wake-ups. Table-stakes calendar feature
182 + - [ ] Task due-date reminders — notification when deadlines approach (1 day before, at due time)
183 + - [ ] Notification settings UI — per-type enable/disable, quiet hours, sound toggle (add to Settings > Notifications section)
184 + - [ ] Workload guardrails — optional notification when scheduled hours exceed daily target
135 185
136 186 ---
137 187
@@ -65,6 +65,7 @@ const api = {
65 65 listFiltered: (filters) => invoke('list_tasks_filtered', { filters }), // Server-side filter + paginate
66 66 listByProject: (projectId) => invoke('list_tasks_for_project', { projectId }),
67 67 get: (id) => invoke('get_task', { id }),
68 + getOverview: (id) => invoke('get_task_overview', { id }),
68 69 create: (input) => invoke('create_task', { input }),
69 70 quickAdd: (text) => invoke('quick_add_task', { input: { text } }), // Natural language: "Fix bug +work @tomorrow !high"
70 71 update: (id, input) => invoke('update_task', { id, input }),
@@ -105,6 +106,8 @@ const api = {
105 106 create: (input) => invoke('create_event', { input }),
106 107 update: (id, input) => invoke('update_event', { id, input }),
107 108 delete: (id) => invoke('delete_event', { id }),
109 + bulkDelete: (ids) => invoke('bulk_delete_events', { ids }),
110 + listBetween: (start, end) => invoke('list_events_between', { start, end }),
108 111 getStatusIndicator: (leadMinutes) => invoke('get_event_status_indicator', { leadMinutes }),
109 112 },
110 113
@@ -148,6 +151,8 @@ const api = {
148 151 create: (input) => invoke('create_contact', { input }),
149 152 update: (id, input) => invoke('update_contact', { id, input }),
150 153 delete: (id) => invoke('delete_contact', { id }),
154 + bulkDelete: (ids) => invoke('bulk_delete_contacts', { ids }),
155 + bulkTag: (ids, tag) => invoke('bulk_tag_contacts', { ids, tag }),
151 156 addEmail: (contactId, input) => invoke('add_contact_email', { contactId, input }),
152 157 removeEmail: (emailId) => invoke('remove_contact_email', { emailId }),
153 158 addPhone: (contactId, input) => invoke('add_contact_phone', { contactId, input }),
@@ -157,7 +162,12 @@ const api = {
157 162 addCustomField: (contactId, input) => invoke('add_contact_custom_field', { contactId, input }),
158 163 removeCustomField: (fieldId) => invoke('remove_contact_custom_field', { fieldId }),
159 164 findByEmail: (email) => invoke('find_contact_by_email', { email }), // Reverse lookup for email sender → contact
160 - listFiltered: (search, tag) => invoke('list_contacts_filtered', { search: search || null, tag: tag || null }),
165 + validateAddresses: (addresses) => invoke('validate_email_addresses', { addresses }),
166 + promoteContact: (id) => invoke('promote_contact', { id }),
167 + listTasksForContact: (contactId) => invoke('list_tasks_for_contact', { contactId }),
168 + listEventsForContact: (contactId) => invoke('list_events_for_contact', { contactId }),
169 + listEmailsForContact: (contactId) => invoke('list_emails_for_contact', { contactId }),
170 + listFiltered: (search, tag, includeImplicit) => invoke('list_contacts_filtered', { search: search || null, tag: tag || null, includeImplicit: includeImplicit ?? false }),
161 171 },
162 172
163 173 // Email Accounts — IMAP/JMAP account configuration and sync triggers
@@ -220,6 +230,12 @@ const api = {
220 230 saveBackupSettings: (settings) => invoke('save_backup_settings', { input: settings }),
221 231 },
222 232
233 + // Daily Notes — per-day reflection (persisted to SQLite)
234 + dailyNotes: {
235 + get: (date) => invoke('get_daily_note', { date }),
236 + upsert: (input) => invoke('upsert_daily_note', { input }),
237 + },
238 +
223 239 // Weekly Review — GTD-style reflection: focus tasks, vacation, nudges
224 240 weeklyReview: {
225 241 get: () => invoke('get_weekly_review'),
@@ -260,6 +276,8 @@ const api = {
260 276 setupEncryptionNew: (password) => invoke('sync_setup_encryption_new', { password }), // First device
261 277 setupEncryptionExisting: (password) => invoke('sync_setup_encryption_existing', { password }), // Join existing
262 278 updateSettings: (input) => invoke('sync_update_settings', { input }),
279 + subscriptionStatus: () => invoke('sync_subscription_status'),
280 + subscribe: (interval) => invoke('sync_subscribe', { interval }),
263 281 },
264 282
265 283 // Plugins — Rhai-based import plugin management
@@ -10,54 +10,55 @@
10 10 // ============ Cloud Sync ============
11 11
12 12 /**
13 - * Open the cloud sync settings modal, showing the appropriate state
14 - * (not configured / not authenticated / encryption setup / dashboard).
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
15 16 */
16 - async function openCloudSyncModal() {
17 + async function renderSyncSection(container) {
17 18 let status;
18 19 try {
19 20 status = await GoingsOn.api.sync.status();
20 21 } catch (err) {
21 - GoingsOn.ui.showToast('Failed to load sync status: ' + GoingsOn.utils.getErrorMessage(err), 'error');
22 + container.innerHTML = `
23 + <div class="settings-section">
24 + <h3 class="settings-heading">Cloud Sync</h3>
25 + <p style="color: var(--accent-red);">Failed to load sync status: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>
26 + </div>
27 + `;
22 28 return;
23 29 }
24 30
25 - let content;
31 + let sectionContent;
26 32
27 33 if (!status.configured) {
28 34 // State 1: Not configured — prompt for API key
29 - content = `
30 - <div style="padding: 1rem 0;">
31 - <p style="color: var(--text-secondary); margin-bottom: 0.5rem;">
32 - Sync your tasks, events, contacts, and projects across devices with end-to-end encryption.
33 - All data is encrypted on your device before it leaves — the server never sees your content.
34 - </p>
35 - <p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem;">
36 - Enter your SyncKit API key to get started.
37 - </p>
38 - <p style="margin-bottom: 1rem;">
39 - <a href="https://makenot.work/docs/synckit-api" target="_blank" style="font-size: 0.875rem;">Get an API key</a>
40 - </p>
41 - <div class="form-group">
42 - <label class="form-label" for="sync-api-key">API Key</label>
43 - <div style="display: flex; gap: 0.5rem;">
44 - <input type="password" class="form-input" id="sync-api-key" placeholder="sk_..." style="flex: 1;">
45 - <button class="btn btn-secondary" id="sync-test-btn" onclick="GoingsOn.settings.testSyncApiKey()">Test</button>
46 - </div>
47 - </div>
48 - <div id="sync-key-status" style="font-size: 0.875rem; margin-top: 0.5rem; display: none;"></div>
49 - <div id="sync-save-row" style="margin-top: 1rem; display: none;">
50 - <button class="btn btn-primary" onclick="GoingsOn.settings.saveSyncApiKey()">Save & Connect</button>
35 + sectionContent = `
36 + <p style="color: var(--text-secondary); margin-bottom: 0.5rem;">
37 + Sync your tasks, events, contacts, and projects across devices with end-to-end encryption.
38 + All data is encrypted on your device before it leaves — the server never sees your content.
39 + </p>
40 + <p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem;">
41 + Enter your SyncKit API key to get started.
42 + </p>
43 + <p style="margin-bottom: 1rem;">
44 + <a href="https://makenot.work/docs/synckit-api" target="_blank" style="font-size: 0.875rem;">Get an API key</a>
45 + </p>
46 + <div class="form-group">
47 + <label class="form-label" for="sync-api-key">API Key</label>
48 + <div style="display: flex; gap: 0.5rem;">
49 + <input type="password" class="form-input" id="sync-api-key" placeholder="sk_..." style="flex: 1;">
50 + <button class="btn btn-secondary" id="sync-test-btn" onclick="GoingsOn.settings.testSyncApiKey()">Test</button>
51 51 </div>
52 52 </div>
53 - <div class="form-actions">
54 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
53 + <div id="sync-key-status" style="font-size: 0.875rem; margin-top: 0.5rem; display: none;"></div>
54 + <div id="sync-save-row" style="margin-top: 1rem; display: none;">
55 + <button class="btn btn-primary" onclick="GoingsOn.settings.saveSyncApiKey()">Save & Connect</button>
55 56 </div>
56 57 `;
57 58 } else if (!status.authenticated) {
58 59 // State 2: Not authenticated
59 - content = `
60 - <div style="text-align: center; padding: 2rem 1rem;">
60 + sectionContent = `
61 + <div style="text-align: center; padding: 2rem 0;">
61 62 <p style="margin-bottom: 1.5rem; color: var(--text-secondary);">
62 63 Connect your Makenot.work account to sync data across devices.
63 64 </p>
@@ -65,9 +66,6 @@
65 66 Connect to Makenot.work
66 67 </button>
67 68 </div>
68 - <div class="form-actions">
69 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
70 - </div>
71 69 `;
72 70 } else if (!status.encryptionReady) {
73 71 // State 3: Authenticated but no encryption
@@ -79,24 +77,21 @@
79 77 ? 'Choose a password to encrypt your synced data. You will need this password on other devices.'
80 78 : 'Enter the encryption password you set up on your first device.';
81 79
82 - content = `
83 - <div style="padding: 1rem 0;">
84 - <p style="margin-bottom: 1rem; color: var(--text-secondary);">
85 - ${esc(hint)}
86 - </p>
87 - <div class="form-group">
88 - <label class="form-label" for="sync-encryption-password">Encryption Password</label>
89 - <input type="password" class="form-input" id="sync-encryption-password" placeholder="Enter password" required>
90 - </div>
91 - ${isNewDevice ? `<div class="form-group">
92 - <label class="form-label" for="sync-encryption-confirm">Confirm Password</label>
93 - <input type="password" class="form-input" id="sync-encryption-confirm" placeholder="Confirm password" required>
94 - </div>` : ''}
95 - <div id="sync-encryption-error" style="color: var(--accent-red); font-size: 0.875rem; margin-top: 0.5rem; display: none;"></div>
80 + sectionContent = `
81 + <p style="margin-bottom: 1rem; color: var(--text-secondary);">
82 + ${esc(hint)}
83 + </p>
84 + <div class="form-group">
85 + <label class="form-label" for="sync-encryption-password">Encryption Password</label>
86 + <input type="password" class="form-input" id="sync-encryption-password" placeholder="Enter password" required>
96 87 </div>
97 - <div class="form-actions">
98 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
99 - <button type="button" class="btn btn-primary" onclick="GoingsOn.settings.submitEncryption(${isNewDevice})">
88 + ${isNewDevice ? `<div class="form-group">
89 + <label class="form-label" for="sync-encryption-confirm">Confirm Password</label>
90 + <input type="password" class="form-input" id="sync-encryption-confirm" placeholder="Confirm password" required>
91 + </div>` : ''}
92 + <div id="sync-encryption-error" style="color: var(--accent-red); font-size: 0.875rem; margin-top: 0.5rem; display: none;"></div>
93 + <div style="margin-top: 1rem;">
94 + <button class="btn btn-primary" onclick="GoingsOn.settings.submitEncryption(${isNewDevice})">
100 95 ${esc(heading)}
101 96 </button>
102 97 </div>
@@ -109,60 +104,119 @@
109 104
110 105 const intervalOptions = [1, 2, 5, 10, 15, 30, 60];
111 106
112 - content = `
113 - <div style="padding: 0.5rem 0;">
114 - <div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem;">
115 - <span style="width: 10px; height: 10px; border-radius: 50%; background: var(--accent-green); display: inline-block;"></span>
116 - <span style="font-weight: 500;">Connected to ${esc(status.serverUrl || 'server')}</span>
117 - </div>
107 + sectionContent = `
108 + <div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem;">
109 + <span style="width: 10px; height: 10px; border-radius: 50%; background: var(--accent-green); display: inline-block;"></span>
110 + <span style="font-weight: 500;">Connected to ${esc(status.serverUrl || 'server')}</span>
111 + </div>
118 112
119 - <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
120 - <div style="padding: 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-md);">
121 - <div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.25rem;">Last Sync</div>
122 - <div style="font-size: 0.9rem;">${esc(lastSync)}</div>
123 - </div>
124 - <div style="padding: 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-md);">
125 - <div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.25rem;">Pending Changes</div>
126 - <div style="font-size: 0.9rem;">${status.pendingChanges}</div>
127 - </div>
113 + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
114 + <div style="padding: 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-md);">
115 + <div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.25rem;">Last Sync</div>
116 + <div style="font-size: 0.9rem;">${esc(lastSync)}</div>
128 117 </div>
129 -
130 - <div style="margin-bottom: 1.5rem;">
131 - <button class="btn btn-primary" onclick="GoingsOn.settings.doSyncNow()" id="sync-now-btn">
132 - Sync Now
133 - </button>
118 + <div style="padding: 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-md);">
119 + <div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.25rem;">Pending Changes</div>
120 + <div style="font-size: 0.9rem;">${status.pendingChanges}</div>
134 121 </div>
122 + </div>
135 123
136 - <div style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-bottom: 1rem;">
137 - <div class="form-group" style="margin-bottom: 1rem;">
138 - <label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
139 - <input type="checkbox" id="sync-auto-enabled" ${status.autoSyncEnabled ? 'checked' : ''} style="width: auto;"
140 - onchange="GoingsOn.settings.updateSyncSettings()">
141 - Enable automatic sync
142 - </label>
143 - </div>
144 - <div class="form-group">
145 - <label class="form-label">Sync Interval</label>
146 - <select id="sync-interval" class="form-select" onchange="GoingsOn.settings.updateSyncSettings()">
147 - ${intervalOptions.map(m => {
148 - const label = m === 1 ? '1 minute' : m + ' minutes';
149 - return '<option value="' + m + '"' + (status.syncIntervalMinutes === m ? ' selected' : '') + '>' + label + '</option>';
150 - }).join('')}
151 - </select>
152 - </div>
124 + <div style="margin-bottom: 1.5rem;">
125 + <button class="btn btn-primary" onclick="GoingsOn.settings.doSyncNow()" id="sync-now-btn">
126 + Sync Now
127 + </button>
128 + </div>
129 +
130 + <div style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-bottom: 1rem;">
131 + <div class="form-group" style="margin-bottom: 1rem;">
132 + <label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
133 + <input type="checkbox" id="sync-auto-enabled" ${status.autoSyncEnabled ? 'checked' : ''} style="width: auto;"
134 + onchange="GoingsOn.settings.updateSyncSettings()">
135 + Enable automatic sync
136 + </label>
137 + </div>
138 + <div class="form-group">
139 + <label class="form-label">Sync Interval</label>
140 + <select id="sync-interval" class="form-select" onchange="GoingsOn.settings.updateSyncSettings()">
141 + ${intervalOptions.map(m => {
142 + const label = m === 1 ? '1 minute' : m + ' minutes';
143 + return '<option value="' + m + '"' + (status.syncIntervalMinutes === m ? ' selected' : '') + '>' + label + '</option>';
144 + }).join('')}
145 + </select>
153 146 </div>
154 147 </div>
155 - <div class="form-actions">
156 - <button type="button" class="btn btn-danger" onclick="GoingsOn.settings.disconnectSync()">
148 +
149 + <div style="border-top: 1px solid var(--border-color); padding-top: 1rem;">
150 + <button class="btn btn-danger" onclick="GoingsOn.settings.disconnectSync()">
157 151 Disconnect
158 152 </button>
159 - <div style="flex: 1;"></div>
160 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Back</button>
161 153 </div>
162 154 `;
163 155 }
164 156
165 - GoingsOn.ui.openModal('Cloud Sync', content);
157 + container.innerHTML = `
158 + <div class="settings-section">
159 + <h3 class="settings-heading">Cloud Sync</h3>
160 + <div id="sync-subscription-banner"></div>
161 + ${sectionContent}
162 + </div>
163 + `;
164 +
165 + // After render: check subscription status and show banner if needed
166 + if (status.encryptionReady) {
167 + checkSubscriptionBanner();
168 + }
169 + }
170 +
171 + /**
172 + * Check subscription status and show/hide the subscription banner.
173 + */
174 + async function checkSubscriptionBanner() {
175 + const banner = document.getElementById('sync-subscription-banner');
176 + if (!banner) return;
177 +
178 + try {
179 + const sub = await GoingsOn.api.sync.subscriptionStatus();
180 + if (sub.active) {
181 + // Subscribed — show small status line
182 + const periodEnd = sub.currentPeriodEnd
183 + ? new Date(sub.currentPeriodEnd).toLocaleDateString()
184 + : '';
185 + banner.innerHTML = `
186 + <div style="padding: 0.5rem 0.75rem; background: var(--bg-secondary); border-radius: var(--radius-md); margin-bottom: 1rem; font-size: 0.85rem; color: var(--text-secondary);">
187 + Sync subscription active${periodEnd ? ' (renews ' + esc(periodEnd) + ')' : ''}
188 + </div>
189 + `;
190 + } else {
191 + // Not subscribed — show subscribe prompt
192 + banner.innerHTML = `
193 + <div style="padding: 1rem; background: var(--bg-warning, var(--bg-secondary)); border: 1px solid var(--border-color); border-radius: var(--radius-md); margin-bottom: 1rem;">
194 + <p style="margin: 0 0 0.75rem 0; font-weight: 500;">Subscription required</p>
195 + <p style="margin: 0 0 1rem 0; font-size: 0.875rem; color: var(--text-secondary);">
196 + Cloud sync requires a subscription ($2/month or $15/year).
197 + Annual billing saves on payment processing fees.
198 + </p>
199 + <div style="display: flex; gap: 0.5rem;">
200 + <button class="btn btn-primary" onclick="GoingsOn.settings.subscribeSyncAnnual()">Subscribe ($15/year)</button>
201 + <button class="btn btn-secondary" onclick="GoingsOn.settings.subscribeSyncMonthly()">$2/month</button>
202 + </div>
203 + </div>
204 + `;
205 + }
206 + } catch (err) {
207 + // Non-fatal — just hide the banner
208 + banner.innerHTML = '';
209 + }
210 + }
211 +
212 + /**
213 + * Re-render the sync section into the current settings content container.
214 + */
215 + async function refreshSyncSection() {
216 + const container = document.getElementById('settings-content');
217 + if (container && GoingsOn.state.currentView === 'settings') {
218 + await renderSyncSection(container);
219 + }
166 220 }
167 221
168 222 async function testSyncApiKey() {
@@ -204,7 +258,7 @@
204 258 await GoingsOn.api.sync.saveApiKey(key);
205 259 GoingsOn.ui.showToast('API key saved!');
206 260 refreshSyncIndicator();
207 - openCloudSyncModal();
261 + refreshSyncSection();
208 262 } catch (err) {
209 263 GoingsOn.ui.showToast('Failed to save API key: ' + GoingsOn.utils.getErrorMessage(err), 'error');
210 264 }
@@ -256,8 +310,7 @@
256 310 });
257 311 GoingsOn.ui.showToast('Connected to Makenot.work!');
258 312 refreshSyncIndicator();
259 - // Refresh the modal to show next state
260 - openCloudSyncModal();
313 + refreshSyncSection();
261 314 } catch (err) {
262 315 GoingsOn.ui.showToast('Auth failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
263 316 }
@@ -305,7 +358,7 @@
305 358 }
306 359 GoingsOn.ui.showToast('Encryption configured!');
307 360 refreshSyncIndicator();
308 - openCloudSyncModal();
361 + refreshSyncSection();
309 362 } catch (err) {
310 363 if (errorDiv) {
311 364 errorDiv.textContent = GoingsOn.utils.getErrorMessage(err);
@@ -324,8 +377,7 @@
324 377 try {
325 378 const result = await GoingsOn.api.sync.syncNow();
326 379 GoingsOn.ui.showToast(`Synced: ${result.pushed} pushed, ${result.pulled} pulled`);
327 - // Refresh the modal to update stats
328 - openCloudSyncModal();
380 + refreshSyncSection();
329 381 } catch (err) {
330 382 GoingsOn.ui.showToast('Sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
331 383 if (btn) {
@@ -360,7 +412,7 @@
360 412 await GoingsOn.api.sync.disconnect();
361 413 GoingsOn.ui.showToast('Disconnected from sync');
362 414 refreshSyncIndicator();
363 - GoingsOn.ui.closeModal();
415 + refreshSyncSection();
364 416 } catch (err) {
365 417 GoingsOn.ui.showToast('Failed to disconnect: ' + GoingsOn.utils.getErrorMessage(err), 'error');
366 418 }
@@ -393,10 +445,28 @@
393 445 }
394 446 }
395 447
448 + /**
449 + * Subscribe to cloud sync with the given interval.
450 + * Opens the Stripe checkout page in the user's browser.
451 + */
452 + async function subscribeSync(interval) {
453 + try {
454 + await GoingsOn.api.sync.subscribe(interval);
455 + GoingsOn.ui.showToast('Opening checkout in your browser...');
456 + } catch (err) {
457 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err), 'error');
458 + }
459 + }
460 +
396 461 // ============ Populate GoingsOn Namespace ============
397 462
398 463 Object.assign(GoingsOn.settings, {
399 - openCloudSync: openCloudSyncModal,
464 + openCloudSync: function() {
465 + GoingsOn.settings.open();
466 + // Small delay to ensure settings view is active before switching section
467 + setTimeout(() => GoingsOn.settings.showSection('sync'), 50);
468 + },
469 + renderSyncSection,
400 470 testSyncApiKey,
401 471 saveSyncApiKey,
402 472 startSyncAuth,
@@ -405,6 +475,8 @@
405 475 updateSyncSettings,
406 476 disconnectSync,
407 477 refreshSyncIndicator,
478 + subscribeSyncAnnual: () => subscribeSync('annual'),
479 + subscribeSyncMonthly: () => subscribeSync('monthly'),
408 480 });
409 481
410 482 })();
@@ -361,3 +361,50 @@ pub async fn sync_update_settings(
361 361
362 362 Ok(true)
363 363 }
364 +
365 + // ============ Subscription Commands ============
366 +
367 + /// Check subscription status for this user + app.
368 + #[tauri::command]
369 + #[instrument(skip_all)]
370 + pub async fn sync_subscription_status(
371 + state: State<'_, Arc<AppState>>,
372 + ) -> Result<synckit_client::SubscriptionStatus, ApiError> {
373 + let client = require_sync_client(&state)?;
374 +
375 + if client.session_info().is_none() {
376 + return Err(ApiError::bad_request("Not authenticated"));
377 + }
378 +
379 + client
380 + .get_subscription_status()
381 + .await
382 + .map_api_err("Failed to check subscription", ApiError::external_service)
383 + }
384 +
385 + /// Create a Stripe Checkout session for subscribing to cloud sync.
386 + /// Opens the checkout URL in the user's default browser.
387 + #[tauri::command]
388 + #[instrument(skip_all)]
389 + pub async fn sync_subscribe(
390 + state: State<'_, Arc<AppState>>,
391 + interval: String,
392 + ) -> Result<String, ApiError> {
393 + let client = require_sync_client(&state)?;
394 +
395 + if client.session_info().is_none() {
396 + return Err(ApiError::bad_request("Not authenticated"));
397 + }
398 +
399 + let response = client
400 + .create_subscription_checkout("standard", &interval)
401 + .await
402 + .map_api_err("Failed to create checkout", ApiError::external_service)?;
403 +
404 + // Open in default browser
405 + if let Err(e) = open::that(&response.checkout_url) {
406 + tracing::warn!(error = %e, "Failed to open browser, returning URL");
407 + }
408 +
409 + Ok(response.checkout_url)
410 + }
@@ -251,6 +251,8 @@ pub fn build_mobile_app() -> tauri::Builder<tauri::Wry> {
251 251 commands::sync_setup_encryption_new,
252 252 commands::sync_setup_encryption_existing,
253 253 commands::sync_update_settings,
254 + commands::sync_subscription_status,
255 + commands::sync_subscribe,
254 256 commands::list_import_plugins,
255 257 commands::list_enabled_import_plugins,
256 258 commands::get_plugins_for_extension,
@@ -193,11 +193,22 @@ pub async fn start_sync_scheduler(app: tauri::AppHandle, cancel: CancellationTok
193 193 }
194 194 }
195 195 Err(e) => {
196 - consecutive_failures += 1;
197 - let backoff_minutes = std::cmp::min(2u64.pow(consecutive_failures), 15);
198 - backoff_until = Some(chrono::Utc::now() + chrono::Duration::minutes(backoff_minutes as i64));
199 - let _ = app.emit("sync:status-changed", "error");
200 - warn!("Auto-sync failed (attempt {}, backoff {}m): {}", consecutive_failures, backoff_minutes, e);
196 + // If the server returned 402 (payment required), stop retrying —
197 + // the user needs to subscribe before sync will work.
198 + let is_payment_required = e.to_string().contains("402");
199 + if is_payment_required {
200 + let _ = app.emit("sync:subscription-required", ());
201 + let _ = app.emit("sync:status-changed", "subscription_required");
202 + warn!("Auto-sync: subscription required, pausing scheduler");
203 + // Back off for 1 hour — recheck after that in case user subscribes
204 + backoff_until = Some(chrono::Utc::now() + chrono::Duration::hours(1));
205 + } else {
206 + consecutive_failures += 1;
207 + let backoff_minutes = std::cmp::min(2u64.pow(consecutive_failures), 15);
208 + backoff_until = Some(chrono::Utc::now() + chrono::Duration::minutes(backoff_minutes as i64));
209 + let _ = app.emit("sync:status-changed", "error");
210 + warn!("Auto-sync failed (attempt {}, backoff {}m): {}", consecutive_failures, backoff_minutes, e);
211 + }
201 212 }
202 213 }
203 214