Skip to main content

max / goingson

UX audit remediation, error handling, day planning, draft autosave Error handling: actionable per-code hints in humanizeApiError, retry buttons on all view load failures, validation errors with format hints, form errors scroll to first invalid field, due date validation. Recurrence: live preview text, monthly nth-weekday behind toggle. Quick wins: kbd shortcut hints on buttons, Focus Timer rename, More/Less options toggle, tab aria-labels, fade-out on task completion. Day planning: paint hint above timeline, cursor:grab on slots, sidebar label "Tasks to Schedule", drag-to-reschedule timeline items, human- readable time preview in modal, confirmation toasts with time range. Email: compose draft autosave (debounced 2s, Cmd+S manual save, send guard), auto-sync after OAuth account connection. Sync: annual billing messaging with fee rationale, checkout completion polling, sync:subscription-required event listener with subscribe action. Reorganize todo into sprint-based structure, archive completed items. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 21:17 UTC
Commit: ce3f06d062debfac1af978ad48152e0c8d1300ef
Parent: 73d3f4a
22 files changed, +683 insertions, -207 deletions
M docs/todo/todo.md +144 -130
@@ -1,193 +1,207 @@
1 1 # GoingsOn Todo
2 2
3 - Done: Phases 1-9, Phase 5 attachments, code fuzz, email compose (10/10). Next: sync monetization, live sync, desktop distribution.
4 -
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.
3 + v0.3.1. Audit grade A. ~313 tests. Migrations 041-044. Rust 2024 edition. UX audit grade B+.
6 4
7 5 Completed items: [todo_done.md](./todo_done.md)
8 6
9 7 ---
10 8
11 - ## Audit Items (Run 19, 2026-05-04)
9 + ## Sprint: Ship It (pre-launch blockers)
10 +
11 + Revenue, sync, and distribution — the things that must work before real users touch the app.
12 12
13 - - [x] Fix symlink canonicalization in plugin loader — already fixed (verified 2026-05-05)
14 - - [ ] Add execution timeout to Rhai engine (in addition to operation limit) — deferred, low priority
13 + - [x] Annual billing messaging: explains savings + Stripe per-transaction fee rationale (2026-05-07)
14 + - [x] Checkout completion detection: polls subscription status every 5s after Stripe opens (2026-05-07)
15 + - [x] `sync:subscription-required` listener: shows toast with Subscribe action on 402 (2026-05-07)
16 + - [ ] Test full checkout flow against live Stripe (subscribe → webhook → sync gate passes)
17 + - [ ] Test full sync flow against live MNW server
18 + - [ ] OAuth provider registration: Fastmail (pending), Google (test), Microsoft (test)
15 19
16 20 ---
17 21
18 - ## Sync Monetization
22 + ## Sprint: Email Hardening
19 23
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.
24 + Email is the riskiest area — data loss potential + OAuth friction.
21 25
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 - - [ ] 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 + - [x] Compose draft autosave to DB — debounced 2s on all fields, Cmd+S manual save, isSending guard (2026-05-07)
27 + - [ ] Email sync progress indicator during first/full sync
28 + - [ ] OAuth: "Check your browser window" text with visible countdown
29 + - [ ] OAuth failure: actionable hint to retry or switch to IMAP
30 + - [ ] "Recommended" badge on OAuth buttons (vs IMAP)
31 + - [ ] Archive folder auto-detect or folder browser
32 + - [ ] Email label creation UI (read-only badges; backend supports labels)
33 + - [ ] Email folder/label filtering in list UI (backend supports it)
34 + - [ ] Mobile compose: add CC/BCC fields (desktop has them)
27 35
28 36 ---
29 37
30 - ## Phase 1: Desktop Distribution
38 + ## Sprint: Task Row Actions
31 39
32 - ### Mobile Port (Tauri 2, pre-beta)
33 - - [ ] `cargo tauri android init`
34 - - [ ] Test all CRUD operations on mobile WebView
35 - - [ ] Physical device testing and polish
36 - - See [todo_mobile.md](./todo_mobile.md) for full mobile breakdown
40 + Surface key actions directly on task rows instead of burying them in context menus.
37 41
38 - ### Windows Build (pre-beta)
39 - - [ ] `cargo tauri build` on Windows -- verify `.msi` installer
40 - - [ ] Test on Windows (VM or physical)
41 - - [ ] Code-sign with Authenticode certificate
42 + - [ ] Visible Snooze button on task rows (currently context menu / keyboard 's' only)
43 + - [ ] Visible Track Time button on Started task rows (currently tiny icon)
44 + - [ ] Surface Focus/Pomodoro entry point on task rows (buried in detail modal)
45 + - [ ] Show elapsed time directly in task row when timer is active
46 + - [ ] After email-to-task conversion, offer "Start Timer" in success toast
42 47
43 - ### Linux Build
44 - - [ ] AppImage (x86_64 + aarch64), .deb, .rpm (deps: WebKit2GTK 4.1, OpenSSL, libayatana-appindicator)
48 + ---
45 49
46 - ### Package Managers (post-beta)
47 - - [ ] Homebrew Cask (macOS), Flatpak (Linux), winget (Windows)
50 + ## Sprint: Discoverability & Onboarding
51 +
52 + Help new users find what exists without reading docs.
53 +
54 + - [ ] Document context menus in ? overlay — right-click actions undiscoverable
55 + - [ ] Add "Getting Started" item under Help menu to re-trigger welcome tour
56 + - [ ] First-visit guided walkthrough for day planning
57 + - [ ] Standardize empty-state styling across all views
58 + - [ ] Project list empty state — "No projects yet" message with CTA
59 + - [ ] Dashboard empty sections: add icons + action buttons
60 + - [ ] Quick Add syntax: add "or use Quick Add syntax" link in standard New Task form
61 + - [ ] Surface advanced search syntax (`is:overdue`, `tag:work`) in filter bar and ? overlay
62 + - [ ] Command palette: inline suggestions when typing `is:`, `tag:`, `type:`, `in:` prefixes
48 63
49 64 ---
50 65
51 - ## Phase 4: Cloud Sync
66 + ## Sprint: Review Polish
52 67
53 - ### Pre-beta
54 - - [ ] Test full sync flow against live MNW server
68 + Make the weekly/monthly review flow tighter.
55 69
56 - ### Post-beta
57 - - [ ] Recovery key generation (printable paper backup for encryption password)
70 + - [ ] Move reflection textareas above fold (or prompt before Complete)
71 + - [ ] Toggle active-state indication — clearer visual for active pane
72 + - [ ] Focus slot buttons: make visually distinct as selectors, not action buttons
73 + - [ ] Autosave indicator — subtle "Draft saved" feedback
74 + - [ ] "Complete Review" button: tooltip or confirmation explaining what it does
58 75
59 76 ---
60 77
61 - ## Phase 6: Passkey Authentication (WebAuthn/FIDO2)
78 + ## Sprint: Timer & Focus
62 79
63 - ### Pre-beta
64 - - [ ] Local biometric unlock (Touch ID, Windows Hello)
80 + Distinguish and surface the two time features.
65 81
66 - ### Post-beta
67 - - [ ] Hardware security key support (YubiKey, SoloKey)
68 - - [ ] Cloud passkey authentication
69 - - [ ] E2EE key protection (passkey PRF extension)
82 + - [ ] Floating timer widget: colored border or animation for prominence
83 + - [ ] Distinguish time tracking vs focus timer in UI (two features, no differentiation)
84 + - [ ] Time tracking reports — per-project breakdown, estimated vs actual
70 85
71 86 ---
72 87
73 - ## Phase 7: Plugin System (Rhai)
88 + ## Sprint: Contacts Cleanup
74 89
75 - ### Pre-beta
76 - - [ ] Starter import plugins: Todoist, Things 3, Apple Reminders, Google Tasks
77 - - [ ] Contact import plugins: vCard, Apple, Google, Microsoft
78 - - [ ] Calendar sync: Google, Apple/iCloud CalDAV, generic CalDAV, .ics import
90 + The contacts module is functional but rough around the edges.
79 91
80 - ### Post-beta
81 - - [ ] Export adapters, custom commands, lifecycle hooks
82 - - [ ] Hot-reload AST cache, install from URL, update checking
83 - - [ ] Additional import plugins: TaskWarrior, Notion, Trello
84 - - [ ] Export plugins: CSV, JSON, Markdown, ICS, Obsidian
85 - - [ ] Contact sync: CardDAV, Google, Microsoft Graph, LinkedIn import
86 - - [ ] External tools: Raycast, Alfred, iOS Shortcuts, Zapier/Make webhooks
92 + - [ ] Contact form: "More options" fold (show Name + Notes, fold Nickname/Company/Title/Tags/Birthday/Timezone)
93 + - [ ] Contact bulk tag: replace `window.prompt()` with proper modal + tag autocomplete
94 + - [ ] Contact duplicate detection — check by email, offer merge on import
87 95
88 96 ---
89 97
90 - ## Phase 9: UX Polish — 9D Tab Order
98 + ## Sprint: Calendar Gaps
91 99
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
100 + Events work for basic scheduling but miss calendar-app table stakes.
101 +
102 + - [ ] All-day event support — `isAllDay` flag, full-width strip above timeline
103 + - [ ] Events spanning midnight — handle start before / end after boundary
104 + - [ ] Month-level task grid view (currently events only)
94 105
95 106 ---
96 107
97 - ## OAuth Provider Registration
108 + ## Sprint: Power User Features
109 +
110 + For intermediate-to-advanced users who want more control.
98 111
99 - - [ ] Fastmail: registration pending (email sent)
100 - - [ ] Google: end-to-end OAuth flow tested
101 - - [ ] Microsoft: end-to-end OAuth flow tested
102 - - [ ] Fastmail: client ID set + tested
112 + - [ ] Quick Add ('q'): parse due/project/tag from description text
113 + - [ ] Saved searches / smart filters — persist filter combinations across sessions
114 + - [ ] Task templates for repetitive multi-step tasks
115 + - [ ] Add batch project linking — select multiple tasks, link to project in one action
116 + - [ ] Workload guardrails in day planner — warn when scheduled hours exceed target
103 117
104 118 ---
105 119
106 - ## Shared Code Extraction (Cross-Project)
120 + ## Sprint: Settings & Sync UX
107 121
108 - - [ ] Updater UI: extract nearly-identical updater.js from GO/BB into shared module
109 - - [ ] Theme loading: deduplicate TOML theme parser across GO/BB/AF
110 - - [ ] Rhai host functions: deduplicate plugin runtime setup across GO/BB/AF
111 - - [ ] Saved queries: unify GO saved views, BB query feeds, AF smart folders into shared pattern
112 - - [ ] FTS5 query building: extract shared SQLite full-text search utilities
122 + Small polish items in settings and sync status.
123 +
124 + - [ ] Brief descriptions on each settings section in sidebar
125 + - [ ] Cloud sync indicator visible when configured (currently `display:none`)
126 + - [ ] Sync indicator: expand to show "Syncing..." / "Sync error" on hover
127 + - [ ] Backup settings: simplify to On/Off toggle, "Customize" link for frequency/retention
128 + - [ ] Add "What's New" dialog after OTA updates — surface CHANGELOG.md in-app
113 129
114 130 ---
115 131
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
142 -
143 - ### Discoverability
144 - - [ ] Add touch gesture hints on first mobile use (long-press, swipe, pull-to-refresh)
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)
154 -
155 - ### Complexity
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
160 -
161 - ### Feature Completeness
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
169 - - [ ] Time tracking reports — per-project breakdown, estimated-vs-actual, weekly/monthly summaries
170 - - [ ] Contacts export to vCard — import exists but no export (asymmetric) — deferred, not essential for beta
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)
132 + ## Sprint: Notifications & Reminders
133 +
134 + Shared infrastructure — OS notification API, scheduler, settings UI.
135 +
136 + - [ ] Event reminders — OS notifications before events (5/10/15/30 min configurable)
137 + - [ ] Task due-date reminders — notification when deadlines approach
138 + - [ ] Notification settings UI — per-type enable/disable, quiet hours, sound toggle
139 +
140 + ---
141 +
142 + ## Sprint: Desktop Distribution
143 +
144 + Ship on all platforms.
145 +
146 + ### Mobile Port (Tauri 2, pre-beta)
147 + - [ ] `cargo tauri android init`
148 + - [ ] Test all CRUD operations on mobile WebView
149 + - [ ] Physical device testing and polish
150 + - [ ] Touch gesture hints on first mobile use (long-press, swipe)
151 + - [ ] Mobile search entry point (Cmd+K has no touch equivalent)
152 + - See [todo_mobile.md](./todo_mobile.md) for full breakdown
153 +
154 + ### Windows Build
155 + - [ ] `cargo tauri build` on Windows — verify `.msi` installer
156 + - [ ] Test on Windows (VM or physical)
157 + - [ ] Code-sign with Authenticode certificate
158 +
159 + ### Linux Build
160 + - [ ] AppImage (x86_64 + aarch64), .deb, .rpm
161 +
162 + ### Package Managers (post-beta)
163 + - [ ] Homebrew Cask (macOS), Flatpak (Linux), winget (Windows)
174 164
175 165 ---
176 166
177 - ## Phase 10: Notifications & Reminders
167 + ## Post-Beta
168 +
169 + ### Plugin System (Rhai)
170 + - [ ] Starter import plugins: Todoist, Things 3, Apple Reminders, Google Tasks
171 + - [ ] Contact import plugins: vCard, Apple, Google, Microsoft
172 + - [ ] Calendar sync: Google, Apple/iCloud CalDAV, generic CalDAV, .ics import
173 + - [ ] Export adapters, custom commands, lifecycle hooks
174 + - [ ] Hot-reload AST cache, install from URL, update checking
175 +
176 + ### Passkey Authentication
177 + - [ ] Local biometric unlock (Touch ID, Windows Hello)
178 + - [ ] Hardware security key support, cloud passkey auth, E2EE key protection
179 +
180 + ### Cloud Sync
181 + - [ ] Recovery key generation (printable paper backup for encryption password)
182 +
183 + ### Contacts (larger)
184 + - [ ] Contact groups / distribution lists
185 + - [ ] vCard import plugin
186 + - [ ] Relationship types UI (schema exists, no frontend)
187 + - [ ] Contacts export to vCard
178 188
179 - All notification/reminder work bundled into a single phase — shared infrastructure (OS notification API, scheduler, settings UI).
189 + ### Shared Code Extraction (Cross-Project)
190 + - [ ] Updater UI: extract updater.js from GO/BB into shared module
191 + - [ ] Theme loading: deduplicate TOML theme parser across GO/BB/AF
192 + - [ ] Rhai host functions: deduplicate plugin runtime setup
193 + - [ ] Saved queries: unify GO saved views, BB query feeds, AF smart folders
194 + - [ ] FTS5 query building: extract shared SQLite full-text search utilities
180 195
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
196 + ### Misc
197 + - [ ] Paginate beyond 500-item caps — tasks and emails silently cap
198 + - [ ] Add execution timeout to Rhai engine (in addition to operation limit)
185 199
186 200 ---
187 201
188 202 ## Deferred
189 203
190 - - [ ] Co-working feature: E2E encrypted project sharing (XChaCha20-Poly1305, X25519, Argon2, CRDTs, 7 phases)
204 + - [ ] Co-working feature: E2E encrypted project sharing
191 205 - [ ] Portability: publish synckit-client as a crate or use git submodule
192 206 - [ ] Apple Watch app
193 207 - [ ] Home screen widgets (iOS/Android)
@@ -4,6 +4,31 @@ Moved from todo.md to keep the active list focused.
4 4
5 5 ---
6 6
7 + ## UX Audit Run 20 — Error Handling, Recurrence, Quick Wins, Day Planning (2026-05-07)
8 +
9 + - [x] Actionable error messages with per-code hints (VALIDATION_ERROR, AUTH_ERROR, PARSE_ERROR, etc.)
10 + - [x] Retry buttons in error toasts for all view load failures and sync
11 + - [x] Validation errors suggest expected format; form errors scroll to first invalid field
12 + - [x] Due date field validation with natural language hint on failure
13 + - [x] Recurrence preview text ("Repeats every 2 weeks on Mon, Wed, Fri")
14 + - [x] Monthly recurrence: day-of-month default, nth weekday behind "Specific weekday" toggle
15 + - [x] Tab aria-labels with full descriptions (Work: tasks and projects, etc.)
16 + - [x] Keyboard shortcut hints on buttons: `+ New Task [n]`, `Search [⌘K]`, project/event/contact `[n]`
17 + - [x] Rename Timer pill → "Focus Timer" with tooltip
18 + - [x] "More options" toggle changes text to "Less options" when expanded
19 + - [x] Day plan: hint moved above timeline as styled banner, cursor:grab on timeline slots
20 + - [x] Day plan sidebar: "Unscheduled Tasks" → "Tasks to Schedule"
21 + - [x] Plan/Review toggle tooltips on all three views
22 + - [x] Estimated Minutes field hint
23 + - [x] Fade-out animation on task completion (slide-right + opacity, 250ms)
24 + - [x] Auto-sync after OAuth account connection
25 + - [x] Drag-to-reschedule timeline items (mousedown drag, 15min snap, tasks + events)
26 + - [x] Human-readable time preview in painted event modal with duration
27 + - [x] Confirmation toast with time range on all schedule paths
28 + - [x] Add placeholder/hint text to advanced form fields
29 +
30 + ---
31 +
7 32 ## Overflow Menus + Settings Page (2026-05-06)
8 33
9 34 From usability audit discoverability and learnability findings.
@@ -340,6 +340,8 @@
340 340 }
341 341
342 342 async function sendEmail() {
343 + isSending = true;
344 + clearTimeout(autosaveTimer);
343 345 const accountId = document.getElementById('from-account').value;
344 346 const toAddress = document.getElementById('to-address').value.trim();
345 347 const ccAddress = document.getElementById('cc-address').value.trim();
@@ -420,30 +422,65 @@
420 422 }
421 423
422 424 let currentDraftId = null;
425 + let autosaveTimer = null;
426 + let isSending = false;
423 427
424 - async function saveDraft() {
425 - const accountId = document.getElementById('from-account').value || null;
426 - const toAddress = document.getElementById('to-address').value.trim();
427 - const ccAddress = document.getElementById('cc-address').value.trim();
428 - const bccAddress = document.getElementById('bcc-address').value.trim();
428 + /**
429 + * Collect current form data into a DraftInput object.
430 + */
431 + function collectDraftInput() {
432 + return {
433 + id: currentDraftId || null,
434 + accountId: document.getElementById('from-account').value || null,
435 + toAddress: document.getElementById('to-address').value.trim() || null,
436 + ccAddress: document.getElementById('cc-address').value.trim() || null,
437 + bccAddress: document.getElementById('bcc-address').value.trim() || null,
438 + subject: document.getElementById('subject').value.trim() || null,
439 + body: document.getElementById('body').value || null,
440 + inReplyTo: replyContext.inReplyTo,
441 + references: replyContext.references,
442 + threadId: replyContext.threadId,
443 + };
444 + }
445 +
446 + /**
447 + * Check if the compose form has any meaningful content worth saving.
448 + */
449 + function hasContent() {
450 + const to = document.getElementById('to-address').value.trim();
429 451 const subject = document.getElementById('subject').value.trim();
430 - const body = document.getElementById('body').value;
452 + const body = document.getElementById('body').value.trim();
453 + // Don't count signature-only body as content
454 + const sigOnly = currentSignature && body === '-- \n' + currentSignature;
455 + return !!(to || subject || (body && !sigOnly));
456 + }
457 +
458 + /**
459 + * Debounced autosave — saves draft 2s after last keystroke.
460 + * Only saves if there's meaningful content. Shows subtle status update.
461 + */
462 + function scheduleAutosave() {
463 + if (isSending) return;
464 + clearTimeout(autosaveTimer);
465 + autosaveTimer = setTimeout(autosaveDraft, 2000);
466 + }
467 +
468 + async function autosaveDraft() {
469 + if (isSending || !hasContent()) return;
431 470
432 471 try {
433 - const result = await invoke('save_email_draft', {
434 - input: {
435 - id: currentDraftId || null,
436 - accountId: accountId,
437 - toAddress: toAddress || null,
438 - ccAddress: ccAddress || null,
439 - bccAddress: bccAddress || null,
440 - subject: subject || null,
441 - body: body || null,
442 - inReplyTo: replyContext.inReplyTo,
443 - references: replyContext.references,
444 - threadId: replyContext.threadId,
445 - }
446 - });
472 + const result = await invoke('save_email_draft', { input: collectDraftInput() });
473 + currentDraftId = result.id;
474 + setStatus('Draft auto-saved', 'success');
475 + } catch (_) {
476 + // Autosave failures are silent — manual save still works
477 + }
478 + }
479 +
480 + async function saveDraft() {
481 + clearTimeout(autosaveTimer); // Cancel pending autosave
482 + try {
483 + const result = await invoke('save_email_draft', { input: collectDraftInput() });
447 484 currentDraftId = result.id;
448 485 setStatus('Draft saved!', 'success');
449 486 } catch (err) {
@@ -688,6 +725,10 @@
688 725 if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
689 726 sendEmail();
690 727 }
728 + if ((e.ctrlKey || e.metaKey) && e.key === 's') {
729 + e.preventDefault();
730 + saveDraft();
731 + }
691 732 });
692 733
693 734 // Initialize
@@ -788,6 +829,12 @@
788 829 } else {
789 830 document.getElementById('to-address').focus();
790 831 }
832 +
833 + // Wire up debounced autosave on all compose fields
834 + for (const id of ['to-address', 'cc-address', 'bcc-address', 'subject', 'body']) {
835 + document.getElementById(id).addEventListener('input', scheduleAutosave);
836 + }
837 + document.getElementById('from-account').addEventListener('change', scheduleAutosave);
791 838 });
792 839 </script>
793 840 </body>
@@ -752,10 +752,15 @@ body {
752 752 .task-row {
753 753 padding: 0.75rem 1.25rem;
754 754 border-bottom: 1px solid var(--border-color);
755 - transition: background-color 0.15s ease;
755 + transition: background-color 0.15s ease, opacity 0.25s ease, transform 0.25s ease;
756 756 cursor: pointer;
757 757 }
758 758
759 + .task-row-removing {
760 + opacity: 0;
761 + transform: translateX(20px);
762 + }
763 +
759 764 .task-row:hover {
760 765 background-color: var(--bg-secondary);
761 766 }
@@ -1918,6 +1923,10 @@ kbd {
1918 1923 font-size: 1.25rem;
1919 1924 }
1920 1925
1926 + .kbd-hint {
1927 + display: none;
1928 + }
1929 +
1921 1930 .cards-grid {
1922 1931 grid-template-columns: 1fr;
1923 1932 }
@@ -2859,8 +2868,20 @@ kbd {
2859 2868 transform: translateY(-0.5em);
2860 2869 }
2861 2870
2871 + .timeline-hint {
2872 + text-align: center;
2873 + color: var(--text-secondary);
2874 + font-size: 0.85rem;
2875 + font-weight: 600;
2876 + margin: 0 0 0.5rem;
2877 + padding: 0.35rem 0.75rem;
2878 + background: var(--bg-secondary);
2879 + border-radius: var(--radius-sm);
2880 + }
2881 +
2862 2882 .timeline-slot-area {
2863 2883 position: relative;
2884 + cursor: grab;
2864 2885 }
2865 2886
2866 2887 .timeline-slot-area:hover {
@@ -2875,8 +2896,9 @@ kbd {
2875 2896 border-radius: var(--radius-sm);
2876 2897 padding: 0.25rem 0.5rem;
2877 2898 overflow: hidden;
2878 - cursor: pointer;
2899 + cursor: grab;
2879 2900 z-index: 10;
2901 + transition: opacity 0.15s ease, box-shadow 0.15s ease;
2880 2902 }
2881 2903
2882 2904 .timeline-item.task {
@@ -2970,6 +2992,13 @@ kbd {
2970 2992 pointer-events: none;
2971 2993 }
2972 2994
2995 + .timeline-item.dragging {
2996 + cursor: grabbing;
2997 + opacity: 0.8;
2998 + z-index: 100;
2999 + box-shadow: var(--shadow-brutal-md, 4px 4px 0 var(--border-color));
3000 + }
3001 +
2973 3002 .timeline-container.is-painting {
2974 3003 cursor: crosshair;
2975 3004 user-select: none;
@@ -3059,6 +3088,20 @@ kbd {
3059 3088 padding: 0.5rem;
3060 3089 }
3061 3090
3091 + .kbd-hint {
3092 + display: inline-block;
3093 + font-family: var(--font-mono, monospace);
3094 + font-size: 0.65rem;
3095 + font-weight: 600;
3096 + padding: 0.1rem 0.35rem;
3097 + margin-left: 0.35rem;
3098 + border: 1px solid currentColor;
3099 + border-radius: 3px;
3100 + opacity: 0.6;
3101 + vertical-align: middle;
3102 + line-height: 1;
3103 + }
3104 +
3062 3105 .settings-section h3 {
3063 3106 font-size: 1rem;
3064 3107 color: var(--text-primary);
@@ -17,13 +17,13 @@
17 17 <span class="mobile-view-title" id="mobile-view-title"></span>
18 18 </div>
19 19 <nav class="tab-navigation" role="tablist" aria-label="Main navigation">
20 - <a href="#" class="tab active" data-view="work" role="tab" aria-selected="true">
20 + <a href="#" class="tab active" data-view="work" role="tab" aria-selected="true" aria-label="Work: tasks and projects">
21 21 <span class="tab-label">Work</span>
22 22 </a>
23 - <a href="#" class="tab" data-view="time" role="tab" aria-selected="false">
23 + <a href="#" class="tab" data-view="time" role="tab" aria-selected="false" aria-label="Time: day planning, calendar, reviews">
24 24 <span class="tab-label">Time</span>
25 25 </a>
26 - <a href="#" class="tab" data-view="messages" role="tab" aria-selected="false">
26 + <a href="#" class="tab" data-view="messages" role="tab" aria-selected="false" aria-label="Messages: email and contacts">
27 27 <span class="tab-label">Messages</span>
28 28 </a>
29 29 </nav>
@@ -31,7 +31,7 @@
31 31 <button class="sync-indicator" id="sync-indicator" onclick="GoingsOn.settings.openCloudSync()" title="Cloud Sync" aria-label="Cloud sync status" style="display:none;">
32 32 <span class="sync-dot" id="sync-dot"></span>
33 33 </button>
34 - <button class="settings-btn" onclick="GoingsOn.search.open()" title="Search (Cmd+K)" aria-label="Search">Search</button>
34 + <button class="settings-btn" onclick="GoingsOn.search.open()" title="Search (Cmd+K)" aria-label="Search">Search <kbd class="kbd-hint">&#8984;K</kbd></button>
35 35 <button class="settings-btn" onclick="GoingsOn.settings.open()" title="Settings" aria-label="Open settings">Settings</button>
36 36 <button class="settings-btn shortcut-hint-btn" onclick="GoingsOn.keyboard.showShortcuts()" title="Keyboard shortcuts (?)" aria-label="Show keyboard shortcuts">?</button>
37 37 </div>
@@ -56,7 +56,7 @@
56 56 <button class="view-toggle-btn" data-mode="board" onclick="GoingsOn.tasks.setViewMode('board')">Board</button>
57 57 </div>
58 58 <button class="btn btn-secondary" onclick="GoingsOn.keyboard.openQuickAddModal()" title="Quick add (q)">Quick Add</button>
59 - <button class="btn btn-primary" onclick="GoingsOn.tasks.openNew()" title="New task (n)">+ New Task</button>
59 + <button class="btn btn-primary" onclick="GoingsOn.tasks.openNew()" title="New task (n)">+ New Task <kbd class="kbd-hint">n</kbd></button>
60 60 </div>
61 61 </div>
62 62 <div id="task-bulk-actions" class="bulk-actions-bar hidden" role="toolbar" aria-label="Bulk task actions">
@@ -151,7 +151,7 @@
151 151 <div id="projects-view" class="subview hidden" role="tabpanel" aria-labelledby="projects-tab">
152 152 <div class="page-header">
153 153 <h2 class="page-title">Projects</h2>
154 - <button class="btn btn-primary" onclick="GoingsOn.projects.openNew()" title="New project (n)">+ New Project</button>
154 + <button class="btn btn-primary" onclick="GoingsOn.projects.openNew()" title="New project (n)">+ New Project <kbd class="kbd-hint">n</kbd></button>
155 155 </div>
156 156 <div class="cards-grid" id="projects-grid">
157 157 <div class="skeleton-shimmer" aria-label="Loading projects">
@@ -238,7 +238,7 @@
238 238 <button class="pill" data-subview="weekly-review">Weekly Review</button>
239 239 <button class="pill" data-subview="monthly-review">Monthly Review</button>
240 240 <button class="pill" data-subview="events">Events</button>
241 - <button class="pill" data-subview="timer">Timer</button>
241 + <button class="pill" data-subview="timer" title="Pomodoro-style focus sessions">Focus Timer</button>
242 242 </div>
243 243
244 244 <!-- Day Plan Sub-View -->
@@ -252,15 +252,15 @@
252 252 <span id="day-plan-date-display" class="day-plan-date-display"></span>
253 253 </div>
254 254 <div class="view-toggle" id="day-plan-review-toggle" data-plan-pane="day-plan-pane" data-review-pane="day-review-pane">
255 - <button class="view-toggle-btn active" data-mode="plan" onclick="GoingsOn.planReviewToggle.setMode('day', 'plan')">Plan</button>
256 - <button class="view-toggle-btn" data-mode="review" onclick="GoingsOn.planReviewToggle.setMode('day', 'review')">Review</button>
255 + <button class="view-toggle-btn active" data-mode="plan" onclick="GoingsOn.planReviewToggle.setMode('day', 'plan')" title="Schedule your day">Plan</button>
256 + <button class="view-toggle-btn" data-mode="review" onclick="GoingsOn.planReviewToggle.setMode('day', 'review')" title="Reflect on your day">Review</button>
257 257 </div>
258 258 </div>
259 259 <!-- Plan pane -->
260 260 <div id="day-plan-pane" class="day-plan-content">
261 261 <div class="day-plan-main" id="day-plan-container">
262 + <p class="timeline-hint">Click and drag across time slots to schedule blocks</p>
262 263 <div class="timeline-container" id="timeline-container">
263 - <p class="timeline-hint" style="text-align: center; color: var(--text-secondary); font-size: 0.8rem; margin: 0.25rem 0 0;">Drag across time slots to block time</p>
264 264 <div class="timeline-scroll-area">
265 265 <div class="timeline-current-time" id="timeline-current-time"></div>
266 266 <div id="timeline-slots"></div>
@@ -271,11 +271,11 @@
271 271 <div class="day-plan-sidebar">
272 272 <div id="time-summary-container"></div>
273 273 <button class="day-plan-sidebar-toggle" onclick="GoingsOn.dayPlan.toggleSidebar()">
274 - <span>Unscheduled Tasks</span>
274 + <span>Tasks to Schedule</span>
275 275 <span class="toggle-arrow">&#x25BC;</span>
276 276 </button>
277 277 <div class="sidebar-header">
278 - <h3>Unscheduled Tasks</h3>
278 + <h3>Tasks to Schedule</h3>
279 279 </div>
280 280 <div id="unscheduled-tasks" class="sidebar-task-list" role="list">
281 281 <!-- Virtual scroller renders items here -->
@@ -295,8 +295,8 @@
295 295 <div class="page-header">
296 296 <h2 class="page-title">Weekly Review</h2>
297 297 <div class="view-toggle" id="week-plan-review-toggle" data-plan-pane="week-plan-pane" data-review-pane="week-review-pane">
298 - <button class="view-toggle-btn active" data-mode="plan" onclick="GoingsOn.planReviewToggle.setMode('week', 'plan')">Plan</button>
299 - <button class="view-toggle-btn" data-mode="review" onclick="GoingsOn.planReviewToggle.setMode('week', 'review')">Review</button>
298 + <button class="view-toggle-btn active" data-mode="plan" onclick="GoingsOn.planReviewToggle.setMode('week', 'plan')" title="Schedule your week">Plan</button>
299 + <button class="view-toggle-btn" data-mode="review" onclick="GoingsOn.planReviewToggle.setMode('week', 'review')" title="Reflect on your week">Review</button>
300 300 </div>
301 301 </div>
302 302 <div id="weekly-review-content" class="weekly-review-content">
@@ -318,8 +318,8 @@
318 318 <span id="monthly-review-month-display" class="monthly-review-month-display"></span>
319 319 </div>
320 320 <div class="view-toggle" id="month-plan-review-toggle" data-plan-pane="month-plan-pane" data-review-pane="month-review-pane">
321 - <button class="view-toggle-btn active" data-mode="plan" onclick="GoingsOn.planReviewToggle.setMode('month', 'plan')">Plan</button>
322 - <button class="view-toggle-btn" data-mode="review" onclick="GoingsOn.planReviewToggle.setMode('month', 'review')">Review</button>
321 + <button class="view-toggle-btn active" data-mode="plan" onclick="GoingsOn.planReviewToggle.setMode('month', 'plan')" title="Set goals for the month">Plan</button>
322 + <button class="view-toggle-btn" data-mode="review" onclick="GoingsOn.planReviewToggle.setMode('month', 'review')" title="Reflect on your month">Review</button>
323 323 </div>
324 324 </div>
325 325 <div id="monthly-review-content" class="monthly-review-content">
@@ -350,7 +350,7 @@
350 350 <button class="view-toggle-btn" data-mode="month" onclick="GoingsOn.events.setViewMode('month')">Month</button>
351 351 <button class="view-toggle-btn" data-mode="week" onclick="GoingsOn.events.setViewMode('week')">Week</button>
352 352 </div>
353 - <button class="btn btn-primary" onclick="GoingsOn.events.openNew()" title="New event (n)">+ New Event</button>
353 + <button class="btn btn-primary" onclick="GoingsOn.events.openNew()" title="New event (n)">+ New Event <kbd class="kbd-hint">n</kbd></button>
354 354 </div>
355 355 </div>
356 356 <div id="events-bulk-bar" class="bulk-actions-bar hidden">
@@ -479,7 +479,7 @@
479 479 <select class="filter-select" id="contacts-tag-filter" onchange="GoingsOn.contacts.filterByTag(this.value)">
480 480 <option value="">All Tags</option>
481 481 </select>
482 - <button class="btn btn-primary" onclick="GoingsOn.contacts.openNew()" title="New contact (n)">+ New Contact</button>
482 + <button class="btn btn-primary" onclick="GoingsOn.contacts.openNew()" title="New contact (n)">+ New Contact <kbd class="kbd-hint">n</kbd></button>
483 483 </div>
484 484 </div>
485 485 <div id="contacts-bulk-bar" class="bulk-actions-bar hidden">
@@ -142,6 +142,14 @@ if (window.__TAURI__) {
142 142 refreshCurrentViewData();
143 143 });
144 144
145 + // Cloud sync: subscription required (402 from server)
146 + listen('sync:subscription-required', () => {
147 + GoingsOn.ui.showToast('Cloud sync paused — subscription required', 'error', {
148 + action: { label: 'Subscribe', fn: () => GoingsOn.settings.openCloudSync() },
149 + duration: 10000,
150 + });
151 + });
152 +
145 153 // Cloud sync: status changed (syncing/idle/error)
146 154 listen('sync:status-changed', (event) => {
147 155 const dot = document.getElementById('sync-dot');
@@ -415,6 +415,10 @@
415 415 GoingsOn.cache.markLoaded('contacts');
416 416 } catch (err) {
417 417 GoingsOn.utils.showError(grid, err, 'Failed to load contacts');
418 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load contacts'), 'error', {
419 + action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('contacts'); load(); } },
420 + duration: 8000,
421 + });
418 422 }
419 423 }
420 424
@@ -157,14 +157,17 @@
157 157 </div>
158 158 </div>
159 159
160 + <div id="paint-time-preview" class="form-hint" style="font-weight: 600; color: var(--accent-primary); margin-bottom: 0.5rem;"></div>
160 161 <div class="form-row" style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
161 162 <div class="form-group">
162 163 <label class="form-label">Start</label>
163 - <input type="datetime-local" class="form-input" name="start_time" value="${startISO}">
164 + <input type="datetime-local" class="form-input" name="start_time" value="${startISO}"
165 + onchange="GoingsOn.dayPlan.updatePaintTimePreview()">
164 166 </div>
165 167 <div class="form-group">
166 168 <label class="form-label">End</label>
167 - <input type="datetime-local" class="form-input" name="end_time" value="${endISO}">
169 + <input type="datetime-local" class="form-input" name="end_time" value="${endISO}"
170 + onchange="GoingsOn.dayPlan.updatePaintTimePreview()">
168 171 </div>
169 172 </div>
170 173
@@ -181,6 +184,9 @@
181 184 `;
182 185 GoingsOn.ui.openModal('Create Event', content);
183 186
187 + // Show initial time preview
188 + updatePaintTimePreview();
189 +
184 190 // Default to "Link to Task" when there are unscheduled tasks
185 191 if (unscheduledTasks.length > 0) {
186 192 const modeSelect = document.querySelector('#painted-event-form select[name="item_mode"]');
@@ -212,6 +218,35 @@
212 218 if (locationGroup) locationGroup.style.display = mode === 'block' ? 'none' : 'block';
213 219 }
214 220
221 + /**
222 + * Update the human-readable time preview above the datetime inputs.
223 + */
224 + function updatePaintTimePreview() {
225 + const form = document.getElementById('painted-event-form');
226 + const preview = document.getElementById('paint-time-preview');
227 + if (!form || !preview) return;
228 +
229 + const startVal = form.start_time?.value;
230 + const endVal = form.end_time?.value;
231 + if (!startVal || !endVal) { preview.textContent = ''; return; }
232 +
233 + const start = new Date(startVal);
234 + const end = new Date(endVal);
235 + const timeOpts = { hour: 'numeric', minute: '2-digit' };
236 + const duration = Math.round((end - start) / 60000);
237 +
238 + let durationLabel = '';
239 + if (duration > 0) {
240 + const hours = Math.floor(duration / 60);
241 + const mins = duration % 60;
242 + durationLabel = hours > 0
243 + ? (mins > 0 ? ` (${hours}h ${mins}m)` : ` (${hours}h)`)
244 + : ` (${mins}m)`;
245 + }
246 +
247 + preview.textContent = `${start.toLocaleTimeString('en-US', timeOpts)} \u2013 ${end.toLocaleTimeString('en-US', timeOpts)}${durationLabel}`;
248 + }
249 +
215 250 async function submitPaintedEvent() {
216 251 const form = document.getElementById('painted-event-form');
217 252 const mode = form.item_mode.value;
@@ -220,6 +255,11 @@
220 255 const duration = Math.round((new Date(form.end_time.value) - new Date(form.start_time.value)) / 60000);
221 256
222 257 try {
258 + const timeOpts = { hour: 'numeric', minute: '2-digit' };
259 + const startLabel = new Date(form.start_time.value).toLocaleTimeString('en-US', timeOpts);
260 + const endLabel = new Date(form.end_time.value).toLocaleTimeString('en-US', timeOpts);
261 + const timeRange = `${startLabel} \u2013 ${endLabel}`;
262 +
223 263 if (mode === 'task') {
224 264 const linkedTaskId = form.linked_task_id.value;
225 265 if (!linkedTaskId) {
@@ -227,7 +267,7 @@
227 267 return;
228 268 }
229 269 await GoingsOn.api.dayPlanning.scheduleTask(linkedTaskId, { startTime, duration });
230 - GoingsOn.ui.showToast('Task scheduled!', 'success');
270 + GoingsOn.ui.showToast(`Task scheduled for ${timeRange}`, 'success');
231 271 } else if (mode === 'block') {
232 272 const blockType = form.block_type.value;
233 273 const title = form.title.value.trim() || GoingsOn.dayPlanRender.BLOCK_TYPE_LABELS[blockType] || blockType;
@@ -240,7 +280,7 @@
240 280 projectId: null,
241 281 blockType,
242 282 });
243 - GoingsOn.ui.showToast('Time block created!', 'success');
283 + GoingsOn.ui.showToast(`Time block created: ${timeRange}`, 'success');
244 284 } else {
245 285 if (!form.title.value.trim()) {
246 286 GoingsOn.ui.showToast('Title is required for standalone events', 'error');
@@ -254,7 +294,7 @@
254 294 location: form.location.value || null,
255 295 projectId: null,
256 296 });
257 - GoingsOn.ui.showToast('Event created!', 'success');
297 + GoingsOn.ui.showToast(`Event created: ${timeRange}`, 'success');
258 298 }
259 299 GoingsOn.ui.closeModal();
260 300 await GoingsOn.dayPlan.load();
@@ -272,6 +312,7 @@
272 312 onPaintEnd,
273 313 openPaintedEventModal,
274 314 togglePaintMode,
315 + updatePaintTimePreview,
275 316 submitPaintedEvent,
276 317 };
277 318
@@ -104,9 +104,11 @@
104 104 style="top: ${topOffset}px; height: ${height}px;"
105 105 data-id="${escAttr(item.id)}"
106 106 data-type="${escAttr(item.itemType)}"
107 + data-duration="${duration}"
107 108 onclick="GoingsOn.dayPlan.openTimelineItem('${escAttr(item.id)}', '${escAttr(item.itemType)}')"
109 + onmousedown="GoingsOn.dayPlan.onItemDragStart(event, '${escAttr(item.id)}', '${escAttr(item.itemType)}')"
108 110 onkeydown="GoingsOn.dayPlan.handleTimelineItemKeydown(event, '${escAttr(item.id)}', '${escAttr(item.itemType)}')"
109 - title="${esc(item.title)}${keyboardHint}"
111 + title="${esc(item.title)} (drag to reschedule)${keyboardHint}"
110 112 tabindex="0" role="button" aria-label="${esc(item.title)}${keyboardHint}">
111 113 <div class="timeline-item-title">${esc(item.title)}</div>
112 114 <div class="timeline-item-meta">${esc(metaText)}</div>
@@ -127,7 +127,9 @@
127 127 GoingsOn.dayPlan.load();
128 128 }
129 129
130 - GoingsOn.ui.showToast('Task scheduled successfully');
130 + const startDisplay = new Date(datetimeInput.value).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
131 + const endDisplay = new Date(new Date(datetimeInput.value).getTime() + duration * 60000).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
132 + GoingsOn.ui.showToast(`Task scheduled for ${startDisplay} \u2013 ${endDisplay}`, 'success');
131 133 } catch (err) {
132 134 GoingsOn.ui.showToast('Failed to schedule task: ' + err, 'error');
133 135 }
@@ -57,7 +57,10 @@
57 57 if (!swipeNavCleanup) initSwipeDayNav();
58 58 } catch (err) {
59 59 console.error('Failed to load day planning:', err);
60 - GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load day planning'), 'error');
60 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load day planning'), 'error', {
61 + action: { label: 'Retry', fn: load },
62 + duration: 8000,
63 + });
61 64 }
62 65 }
63 66
@@ -286,6 +289,104 @@
286 289 }
287 290 }
288 291
292 + // ============ Drag-to-Reschedule ============
293 +
294 + let dragState = null;
295 +
296 + /**
297 + * Start dragging a timeline item to reschedule it.
298 + * @param {MouseEvent} event
299 + * @param {string} itemId - Item ID
300 + * @param {string} itemType - 'task', 'event', or 'block'
301 + */
302 + function onItemDragStart(event, itemId, itemType) {
303 + if (event.button !== 0) return;
304 +
305 + const el = event.currentTarget;
306 + const slotsContainer = document.getElementById('timeline-slots');
307 + if (!slotsContainer) return;
308 +
309 + const startY = event.clientY;
310 + const origTop = parseFloat(el.style.top);
311 + const slotHeight = 12; // must match renderTimeline
312 + let moved = false;
313 +
314 + function onMouseMove(e) {
315 + const dy = e.clientY - startY;
316 + if (!moved && Math.abs(dy) < 5) return; // dead zone to distinguish click from drag
317 + moved = true;
318 + el.classList.add('dragging');
319 +
320 + // Snap to 15-min slots
321 + const slotDelta = Math.round(dy / slotHeight);
322 + const newTop = origTop + slotDelta * slotHeight;
323 + el.style.top = `${Math.max(0, newTop)}px`;
324 + }
325 +
326 + function onMouseUp(e) {
327 + document.removeEventListener('mousemove', onMouseMove);
328 + document.removeEventListener('mouseup', onMouseUp);
329 + el.classList.remove('dragging');
330 +
331 + if (!moved) return; // was a click, not a drag — let onclick handle it
332 +
333 + // Prevent the click event from firing after drag
334 + el.addEventListener('click', function suppress(ev) {
335 + ev.stopPropagation();
336 + el.removeEventListener('click', suppress, true);
337 + }, { capture: true, once: true });
338 +
339 + const dy = e.clientY - startY;
340 + const slotDelta = Math.round(dy / slotHeight);
341 + if (slotDelta === 0) return;
342 +
343 + const deltaMinutes = slotDelta * 15;
344 + rescheduleItem(itemId, itemType, deltaMinutes, parseInt(el.dataset.duration) || 30);
345 + }
346 +
347 + document.addEventListener('mousemove', onMouseMove);
348 + document.addEventListener('mouseup', onMouseUp);
349 + }
350 +
351 + /**
352 + * Reschedule a timeline item by a time delta.
353 + * @param {string} itemId
354 + * @param {string} itemType - 'task', 'event', or 'block'
355 + * @param {number} deltaMinutes - Minutes to shift
356 + * @param {number} duration - Item duration in minutes
357 + */
358 + async function rescheduleItem(itemId, itemType, deltaMinutes, duration) {
359 + if (!GoingsOn.state.dayPlanData) return;
360 +
361 + const item = GoingsOn.state.dayPlanData.timelineItems.find(i => i.id === itemId);
362 + if (!item) return;
363 +
364 + const currentStart = new Date(item.startTime);
365 + const newStart = new Date(currentStart.getTime() + deltaMinutes * 60000);
366 + const newEnd = new Date(newStart.getTime() + duration * 60000);
367 +
368 + // Bounds check
369 + if (newStart.getHours() < 0 || newEnd.getHours() > 23 && newEnd.getMinutes() > 0) return;
370 +
371 + try {
372 + if (itemType === 'task') {
373 + await GoingsOn.api.dayPlanning.scheduleTask(itemId, {
374 + startTime: newStart.toISOString(),
375 + duration,
376 + });
377 + } else {
378 + await GoingsOn.api.events.update(itemId, {
379 + startTime: newStart.toISOString(),
380 + endTime: newEnd.toISOString(),
381 + });
382 + }
383 + await load();
384 + } catch (err) {
385 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to reschedule'), 'error');
386 + await load(); // reload to reset position
387 + }
388 + }
389 +
289 390 // ============ Populate GoingsOn.dayPlan Namespace ============
290 391
291 392 GoingsOn.dayPlan = {
@@ -303,10 +404,13 @@
303 404 unscheduleTask,
304 405 // Unscheduled tasks
305 406 handleUnscheduledTaskKeydown,
407 + // Drag-to-reschedule
408 + onItemDragStart,
306 409 // Painting (delegated to dayPlanPaint)
307 410 onPaintStart: (...a) => GoingsOn.dayPlanPaint.onPaintStart(...a),
308 411 onPaintMove: (...a) => GoingsOn.dayPlanPaint.onPaintMove(...a),
309 412 togglePaintMode: (...a) => GoingsOn.dayPlanPaint.togglePaintMode(...a),
413 + updatePaintTimePreview: (...a) => GoingsOn.dayPlanPaint.updatePaintTimePreview(...a),
310 414 submitPaintedEvent: (...a) => GoingsOn.dayPlanPaint.submitPaintedEvent(...a),
311 415 // Scheduling modal (delegated to dayPlanSchedule)
312 416 openScheduleTaskModal: (...a) => GoingsOn.dayPlanSchedule.openScheduleTaskModal(...a),
@@ -687,8 +687,13 @@ ${esc(result.debugInfo.split(' | ').join('\n'))}
687 687 });
688 688
689 689 pendingOAuthState = null;
690 - GoingsOn.ui.showToast(`Connected ${result.providerName} account: ${result.emailAddress}`, 'success');
690 + GoingsOn.ui.showToast(`Connected ${result.providerName} account: ${result.emailAddress}. Syncing...`, 'success');
691 691 openAccountsModal();
692 +
693 + // Auto-sync the newly connected account
694 + if (result.accountId) {
695 + syncAccount(result.accountId, false);
696 + }
692 697 } catch (err) {
693 698 pendingOAuthState = null;
694 699 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to complete OAuth'), 'error');
@@ -89,7 +89,11 @@
89 89 }
90 90 GoingsOn.cache.markLoaded('emails');
91 91 } catch (err) {
92 - container.innerHTML = `<div class="loading" style="color: var(--accent-red);">Error: ${esc(err.message)}</div>`;
92 + container.innerHTML = `<div class="loading" style="color: var(--accent-red);">Failed to load emails</div>`;
93 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error', {
94 + action: { label: 'Retry', fn: load },
95 + duration: 8000,
96 + });
93 97 }
94 98 }
95 99
@@ -298,7 +298,11 @@
298 298 }
299 299 GoingsOn.cache.markLoaded('events');
300 300 } catch (err) {
301 - upcomingContainer.innerHTML = `<div class="error-state" style="padding: 1rem; color: var(--accent-red);">Error: ${esc(err.message || 'Failed to load events')}</div>`;
301 + upcomingContainer.innerHTML = `<div class="error-state" style="padding: 1rem; color: var(--accent-red);">Failed to load events</div>`;
302 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load events'), 'error', {
303 + action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('events'); load(); } },
304 + duration: 8000,
305 + });
302 306 }
303 307 }
304 308
@@ -115,7 +115,7 @@ function openFormModal(config) {
115 115 if (hasExtended && field.extended && !inExtended) {
116 116 inExtended = true;
117 117 const expanded = extendedHasValues ? 'expanded' : '';
118 - prefix = `<button type="button" class="form-more-toggle ${expanded}" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden');">More options</button><div class="form-extended-fields ${extendedHasValues ? '' : 'hidden'}">`;
118 + prefix = `<button type="button" class="form-more-toggle ${expanded}" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden'); this.textContent = this.classList.contains('expanded') ? 'Less options' : 'More options';">${extendedHasValues ? 'Less options' : 'More options'}</button><div class="form-extended-fields ${extendedHasValues ? '' : 'hidden'}">`;
119 119 }
120 120
121 121 const groupHtml = `
@@ -53,6 +53,10 @@ async function load() {
53 53 if (container) {
54 54 container.innerHTML = `<div class="empty-state">Failed to load monthly review.</div>`;
55 55 }
56 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load monthly review'), 'error', {
57 + action: { label: 'Retry', fn: load },
58 + duration: 8000,
59 + });
56 60 }
57 61 }
58 62
@@ -112,6 +112,10 @@
112 112 GoingsOn.cache.markLoaded('projects');
113 113 } catch (err) {
114 114 GoingsOn.utils.showError(grid, err, 'Failed to load projects');
115 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load projects'), 'error', {
116 + action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('projects'); load(); } },
117 + duration: 8000,
118 + });
115 119 }
116 120 }
117 121
@@ -192,11 +192,14 @@
192 192 banner.innerHTML = `
193 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 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.
195 + <p style="margin: 0 0 0.5rem 0; font-size: 0.875rem; color: var(--text-secondary);">
196 + Cloud sync keeps your tasks, events, contacts, and email settings in sync across devices with end-to-end encryption.
198 197 </p>
199 - <div style="display: flex; gap: 0.5rem;">
198 + <p style="margin: 0 0 1rem 0; font-size: 0.85rem; color: var(--text-secondary);">
199 + <strong>$15/year</strong> (saves $9 vs monthly) or $2/month.
200 + Annual saves you money because Stripe charges a fixed fee per transaction — fewer transactions means more of your payment goes to the service, not processing.
201 + </p>
202 + <div style="display: flex; gap: 0.5rem; align-items: center;">
200 203 <button class="btn btn-primary" onclick="GoingsOn.settings.subscribeSyncAnnual()">Subscribe ($15/year)</button>
201 204 <button class="btn btn-secondary" onclick="GoingsOn.settings.subscribeSyncMonthly()">$2/month</button>
202 205 </div>
@@ -379,7 +382,10 @@
379 382 GoingsOn.ui.showToast(`Synced: ${result.pushed} pushed, ${result.pulled} pulled`);
380 383 refreshSyncSection();
381 384 } catch (err) {
382 - GoingsOn.ui.showToast('Sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
385 + GoingsOn.ui.showToast('Sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', {
386 + action: { label: 'Retry', fn: syncNow },
387 + duration: 8000,
388 + });
383 389 if (btn) {
384 390 btn.disabled = false;
385 391 btn.textContent = 'Sync Now';
@@ -452,12 +458,41 @@
452 458 async function subscribeSync(interval) {
453 459 try {
454 460 await GoingsOn.api.sync.subscribe(interval);
455 - GoingsOn.ui.showToast('Opening checkout in your browser...');
461 + GoingsOn.ui.showToast('Opening checkout in your browser. Complete payment, then return here.');
462 + pollForSubscription();
456 463 } catch (err) {
457 464 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err), 'error');
458 465 }
459 466 }
460 467
468 + /**
469 + * Poll subscription status after checkout opens.
470 + * Checks every 5s for up to 10 minutes.
471 + */
472 + function pollForSubscription() {
473 + let attempts = 0;
474 + const maxAttempts = 120; // 10 minutes at 5s intervals
475 +
476 + const timer = setInterval(async () => {
477 + attempts++;
478 + if (attempts >= maxAttempts) {
479 + clearInterval(timer);
480 + return;
481 + }
482 +
483 + try {
484 + const sub = await GoingsOn.api.sync.subscriptionStatus();
485 + if (sub.active) {
486 + clearInterval(timer);
487 + GoingsOn.ui.showToast('Subscription activated! Sync is now enabled.', 'success');
488 + refreshSyncSection();
489 + }
490 + } catch (_) {
491 + // Ignore polling errors
492 + }
493 + }, 5000);
494 + }
495 +
461 496 // ============ Populate GoingsOn Namespace ============
462 497
463 498 Object.assign(GoingsOn.settings, {
@@ -48,8 +48,11 @@
48 48 const monthlyWd = monthlySpec?.type === 'nthWeekday' ? monthlySpec.weekday : 0;
49 49 const monthlyType = monthlySpec?.type || 'dayOfMonth';
50 50
51 + const hasNthWeekday = monthlyType === 'nthWeekday';
52 +
51 53 return `
52 54 <div id="${prefix}-recurrence-config" class="recurrence-config" style="display: none; margin-top: 0.5rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius-sm);">
55 + <div id="${prefix}-recurrence-preview" class="recurrence-preview" style="font-size: 0.8rem; color: var(--accent-primary); font-weight: 600; margin-bottom: 0.5rem;"></div>
53 56 <div class="form-group" style="margin-bottom: 0.5rem;">
54 57 <label class="form-label" style="font-size: 0.75rem;">Every</label>
55 58 <div style="display: flex; align-items: center; gap: 0.5rem;">
@@ -68,19 +71,22 @@
68 71 <input type="radio" name="${prefix}-monthly-type" value="dayOfMonth" ${monthlyType === 'dayOfMonth' ? 'checked' : ''}>
69 72 Day <input type="number" class="form-input" name="${prefix}-monthly-day" value="${monthlyDom || 1}" min="1" max="31" style="width: 4rem;"> of the month
70 73 </label>
71 - <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem;">
72 - <input type="radio" name="${prefix}-monthly-type" value="nthWeekday" ${monthlyType === 'nthWeekday' ? 'checked' : ''}>
73 - <select class="form-select" name="${prefix}-monthly-week" style="width: auto;">
74 - <option value="1" ${monthlyWeek == 1 ? 'selected' : ''}>1st</option>
75 - <option value="2" ${monthlyWeek == 2 ? 'selected' : ''}>2nd</option>
76 - <option value="3" ${monthlyWeek == 3 ? 'selected' : ''}>3rd</option>
77 - <option value="4" ${monthlyWeek == 4 ? 'selected' : ''}>4th</option>
78 - <option value="-1" ${monthlyWeek == -1 ? 'selected' : ''}>Last</option>
79 - </select>
80 - <select class="form-select" name="${prefix}-monthly-weekday" style="width: auto;">
81 - ${WEEKDAY_LABELS.map((l, i) => `<option value="${i}" ${monthlyWd == i ? 'selected' : ''}>${l}</option>`).join('')}
82 - </select>
83 - </label>
74 + <button type="button" id="${prefix}-monthly-nth-toggle" class="form-more-toggle ${hasNthWeekday ? 'expanded' : ''}" style="font-size: 0.8rem; margin: 0.25rem 0;" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden');">Specific weekday</button>
75 + <div id="${prefix}-monthly-nth-section" class="${hasNthWeekday ? '' : 'hidden'}">
76 + <label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem;">
77 + <input type="radio" name="${prefix}-monthly-type" value="nthWeekday" ${hasNthWeekday ? 'checked' : ''}>
78 + <select class="form-select" name="${prefix}-monthly-week" style="width: auto;">
79 + <option value="1" ${monthlyWeek == 1 ? 'selected' : ''}>1st</option>
80 + <option value="2" ${monthlyWeek == 2 ? 'selected' : ''}>2nd</option>
81 + <option value="3" ${monthlyWeek == 3 ? 'selected' : ''}>3rd</option>
82 + <option value="4" ${monthlyWeek == 4 ? 'selected' : ''}>4th</option>
83 + <option value="-1" ${monthlyWeek == -1 ? 'selected' : ''}>Last</option>
84 + </select>
85 + <select class="form-select" name="${prefix}-monthly-weekday" style="width: auto;">
86 + ${WEEKDAY_LABELS.map((l, i) => `<option value="${i}" ${monthlyWd == i ? 'selected' : ''}>${l}</option>`).join('')}
87 + </select>
88 + </label>
89 + </div>
84 90 </div>
85 91 </div>
86 92 </div>
@@ -93,6 +99,51 @@
93 99 * @param {string} prefix - ID prefix matching buildRecurrenceConfigHtml
94 100 * @param {string} selectName - Name of the recurrence select element
95 101 */
102 + /**
103 + * Build a human-readable description of the current recurrence rule.
104 + * @param {string} prefix - ID prefix for form fields
105 + * @param {string} pattern - Recurrence pattern (Daily, Weekly, Monthly)
106 + * @returns {string} Preview text (e.g., "Repeats every 2 weeks on Mon, Wed, Fri")
107 + */
108 + function buildRecurrencePreview(prefix, pattern) {
109 + if (pattern === 'None') return '';
110 +
111 + const form = document.querySelector('.modal-content form');
112 + if (!form) return '';
113 +
114 + const interval = parseInt(form.elements[`${prefix}-interval`]?.value) || 1;
115 + const unitSingular = { Daily: 'day', Weekly: 'week', Monthly: 'month' }[pattern] || '';
116 + const unitPlural = unitSingular + 's';
117 + const unit = interval === 1 ? unitSingular : `${interval} ${unitPlural}`;
118 +
119 + let detail = '';
120 +
121 + if (pattern === 'Weekly') {
122 + const days = [];
123 + for (let i = 0; i < 7; i++) {
124 + if (form.elements[`${prefix}-weekday-${i}`]?.checked) {
125 + days.push(WEEKDAY_LABELS[i]);
126 + }
127 + }
128 + if (days.length > 0) detail = ` on ${days.join(', ')}`;
129 + }
130 +
131 + if (pattern === 'Monthly') {
132 + const monthlyType = form.querySelector(`[name="${prefix}-monthly-type"]:checked`)?.value || 'dayOfMonth';
133 + if (monthlyType === 'dayOfMonth') {
134 + const day = parseInt(form.elements[`${prefix}-monthly-day`]?.value) || 1;
135 + detail = ` on day ${day}`;
136 + } else {
137 + const weekLabels = { '1': '1st', '2': '2nd', '3': '3rd', '4': '4th', '-1': 'last' };
138 + const week = form.elements[`${prefix}-monthly-week`]?.value || '1';
139 + const wd = parseInt(form.elements[`${prefix}-monthly-weekday`]?.value) || 0;
140 + detail = ` on the ${weekLabels[week] || week} ${WEEKDAY_LABELS[wd]}`;
141 + }
142 + }
143 +
144 + return `Repeats every ${unit}${detail}`;
145 + }
146 +
96 147 function initRecurrenceConfig(prefix, selectName) {
97 148 const form = document.querySelector('.modal-content form');
98 149 if (!form) return;
@@ -102,8 +153,15 @@
102 153 const weekdaysSection = document.getElementById(`${prefix}-weekdays-section`);
103 154 const monthlySection = document.getElementById(`${prefix}-monthly-section`);
104 155 const intervalUnit = document.getElementById(`${prefix}-interval-unit`);
156 + const previewEl = document.getElementById(`${prefix}-recurrence-preview`);
105 157 if (!select || !config) return;
106 158
159 + function updatePreview() {
160 + if (previewEl) {
161 + previewEl.textContent = buildRecurrencePreview(prefix, select.value);
162 + }
163 + }
164 +
107 165 function updateVisibility() {
108 166 const pattern = select.value;
109 167 config.style.display = pattern === 'None' ? 'none' : 'block';
@@ -113,10 +171,32 @@
113 171 const units = { Daily: 'day(s)', Weekly: 'week(s)', Monthly: 'month(s)' };
114 172 intervalUnit.textContent = units[pattern] || 'time(s)';
115 173 }
174 + updatePreview();
116 175 }
117 176
118 177 select.addEventListener('change', updateVisibility);
119 178 updateVisibility();
179 +
180 + // Listen for changes to interval, weekday checkboxes, and monthly options to update preview
181 + const intervalInput = form.elements[`${prefix}-interval`];
182 + if (intervalInput) intervalInput.addEventListener('input', updatePreview);
183 +
184 + for (let i = 0; i < 7; i++) {
185 + const cb = form.elements[`${prefix}-weekday-${i}`];
186 + if (cb) cb.addEventListener('change', updatePreview);
187 + }
188 +
189 + const monthlyDay = form.elements[`${prefix}-monthly-day`];
190 + if (monthlyDay) monthlyDay.addEventListener('input', updatePreview);
191 +
192 + form.querySelectorAll(`[name="${prefix}-monthly-type"]`).forEach(radio => {
193 + radio.addEventListener('change', updatePreview);
194 + });
195 +
196 + const monthlyWeekSel = form.elements[`${prefix}-monthly-week`];
197 + const monthlyWdSel = form.elements[`${prefix}-monthly-weekday`];
198 + if (monthlyWeekSel) monthlyWeekSel.addEventListener('change', updatePreview);
199 + if (monthlyWdSel) monthlyWdSel.addEventListener('change', updatePreview);
120 200 }
121 201
122 202 /**
@@ -256,6 +336,14 @@
256 336 value: dueValue,
257 337 transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
258 338 onInput: GoingsOn.utils.dateParsePreview,
339 + validate: (v) => {
340 + if (!v || !v.trim()) return null;
341 + const parsed = GoingsOn.utils.parseNaturalDate(v);
342 + if (!parsed && !/^\d{4}-\d{2}-\d{2}/.test(v.trim())) {
343 + return 'Date not recognized. Try "tomorrow", "friday 3pm", or "2026-12-25".';
344 + }
345 + return null;
346 + },
259 347 },
260 348 {
261 349 name: 'tags',
@@ -283,6 +371,7 @@
283 371 type: 'number',
284 372 label: 'Estimated Time (minutes)',
285 373 placeholder: 'e.g. 30, 60, 120',
374 + hint: 'Used for day plan scheduling and time tracking progress',
286 375 value: task?.estimatedMinutes || '',
287 376 extended: true,
288 377 },
@@ -97,7 +97,11 @@
97 97 // Update cache with filtered results
98 98 GoingsOn.state.set('tasks', response.tasks);
99 99 } catch (err) {
100 - container.innerHTML = `<div class="loading" style="color: var(--accent-red);">Error: ${esc(err.message || String(err))}</div>`;
100 + container.innerHTML = `<div class="loading" style="color: var(--accent-red);">Failed to load tasks</div>`;
101 + GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load tasks'), 'error', {
102 + action: { label: 'Retry', fn: renderFilteredTasks },
103 + duration: 8000,
104 + });
101 105 return;
102 106 }
103 107
@@ -584,10 +588,17 @@
584 588 async function complete(id) {
585 589 GoingsOn.cache.invalidate('tasks');
586 590
587 - // Optimistically hide from UI
591 + // Animate row out before removing from state
592 + const row = document.querySelector(`.task-row[data-id="${id}"]`);
593 + if (row) row.classList.add('task-row-removing');
594 +
588 595 const cachedTasks = GoingsOn.state.tasks;
589 596 const removedTask = cachedTasks.find(t => t.id === id);
590 - GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
597 +
598 + // Delay state update to let animation play
599 + setTimeout(() => {
600 + GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
601 + }, 250);
591 602
592 603 GoingsOn.ui.showUndoToast('Task completed', {
593 604 onConfirm: async () => {