max / goingson
6 files changed,
+324 insertions,
-124 deletions
| @@ -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 |