max / goingson
22 files changed,
+683 insertions,
-207 deletions
| @@ -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">⌘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">▼</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 () => { |