Skip to main content

max / goingson

UX audit: 11 usability improvements from audit findings Discoverability: - Surface hidden features in task detail modal (edit, notes, timer, focus, schedule, files buttons in subtasks view) - Add g-prefix visual feedback overlay showing available destinations - Show keyboard shortcut hints on major create buttons (title attrs) - Add quick-add syntax popover with live token highlighting Learnability: - Enhance welcome flow with interactive Getting Started checklist - Add real-time date parse preview on all date text fields - Add tooltip/help text for Snooze, Milestone, Recurrence terms - Add frontend error message mapper for structured ApiError codes Complexity: - Extend undo toast window from 5s to 15s - Simplify email account setup (Advanced toggle, live auto-detect) - Use natural language date parsing for milestone target dates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-04 02:58 UTC
Commit: 02150641120929d21c168dd4b325ed8a5e068d68
Parent: 9d731a7
13 files changed, +400 insertions, -129 deletions
M docs/todo/todo.md +63 -61
@@ -2,56 +2,66 @@
2 2
3 3 Done: Phases 1-9. Active: Phase 5 sync tests. Next: Phase 4 live sync, desktop distribution.
4 4
5 - v0.3.0. Audit grade A. ~762 tests.
5 + v0.3.1. Audit grade A. ~762 tests. Code fuzz: 17/18 fixed.
6 +
7 + Completed items: [todo_done.md](./todo_done.md)
6 8
7 9 ---
8 10
9 - ## Phase 1: Desktop Distribution
11 + ## Sync Monetization
12 +
13 + 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.
14 +
15 + - [ ] Stripe product + prices: $2/mo monthly, $15/yr annual
16 + - [ ] Sync gate: check subscription status before enabling SyncKit sync
17 + - [ ] Subscription UI: in-app purchase/manage flow (settings panel)
18 + - [ ] Annual billing messaging: explain why annual is preferred (Stripe fee transparency)
10 19
11 - ### Remaining
20 + ---
21 +
22 + ## Phase 1: Desktop Distribution
12 23
13 - #### Mobile Port (Tauri 2, pre-beta)
24 + ### Mobile Port (Tauri 2, pre-beta)
14 25 - [ ] `cargo tauri android init`
15 26 - [ ] Test all CRUD operations on mobile WebView
16 27 - [ ] Physical device testing and polish
17 28 - See [todo_mobile.md](./todo_mobile.md) for full mobile breakdown
18 29
19 - #### Windows Build (pre-beta)
30 + ### Windows Build (pre-beta)
20 31 - [ ] `cargo tauri build` on Windows -- verify `.msi` installer
21 32 - [ ] Test on Windows (VM or physical)
22 33 - [ ] Code-sign with Authenticode certificate
23 34
24 - #### Linux Build
35 + ### Linux Build
25 36 - [ ] AppImage (x86_64 + aarch64), .deb, .rpm (deps: WebKit2GTK 4.1, OpenSSL, libayatana-appindicator)
26 37
27 - #### Package Managers (post-beta)
38 + ### Package Managers (post-beta)
28 39 - [ ] Homebrew Cask (macOS), Flatpak (Linux), winget (Windows)
29 40
30 41 ---
31 42
32 43 ## Phase 4: Cloud Sync
33 44
34 - ### Remaining (pre-beta)
45 + ### Pre-beta
35 46 - [ ] Test full sync flow against live MNW server
36 47
37 - ### Remaining (post-beta)
48 + ### Post-beta
38 49 - [ ] Recovery key generation (printable paper backup for encryption password)
39 50
40 51 ---
41 52
42 53 ## Phase 5: File Attachments
43 54
44 - ### Remaining
45 - - [ ] Sync tests: attachment in changelog, table_columns whitelist, UPSERT/DELETE ordering
55 + - [x] Sync tests: attachment in changelog, table_columns whitelist, UPSERT/DELETE ordering
46 56
47 57 ---
48 58
49 59 ## Phase 6: Passkey Authentication (WebAuthn/FIDO2)
50 60
51 - ### Remaining (pre-beta)
61 + ### Pre-beta
52 62 - [ ] Local biometric unlock (Touch ID, Windows Hello)
53 63
54 - ### Remaining (post-beta)
64 + ### Post-beta
55 65 - [ ] Hardware security key support (YubiKey, SoloKey)
56 66 - [ ] Cloud passkey authentication
57 67 - [ ] E2EE key protection (passkey PRF extension)
@@ -60,12 +70,12 @@ v0.3.0. Audit grade A. ~762 tests.
60 70
61 71 ## Phase 7: Plugin System (Rhai)
62 72
63 - ### Remaining (pre-beta)
73 + ### Pre-beta
64 74 - [ ] Starter import plugins: Todoist, Things 3, Apple Reminders, Google Tasks
65 75 - [ ] Contact import plugins: vCard, Apple, Google, Microsoft
66 76 - [ ] Calendar sync: Google, Apple/iCloud CalDAV, generic CalDAV, .ics import
67 77
68 - ### Remaining (post-beta)
78 + ### Post-beta
69 79 - [ ] Export adapters, custom commands, lifecycle hooks
70 80 - [ ] Hot-reload AST cache, install from URL, update checking
71 81 - [ ] Additional import plugins: TaskWarrior, Notion, Trello
@@ -84,7 +94,6 @@ v0.3.0. Audit grade A. ~762 tests.
84 94
85 95 ## OAuth Provider Registration
86 96
87 - ### Remaining
88 97 - [ ] Fastmail: registration pending (email sent)
89 98 - [ ] Google: end-to-end OAuth flow tested
90 99 - [ ] Microsoft: end-to-end OAuth flow tested
@@ -94,14 +103,6 @@ v0.3.0. Audit grade A. ~762 tests.
94 103
95 104 ## Email Compose (pre-beta)
96 105
97 - ### Done
98 - - [x] HTML email body conversion to readable markdown via pter (replaces hand-rolled strip_html)
99 - - [x] Reply / Reply-All — two distinct buttons, In-Reply-To/References headers, quoted body, thread joining
100 - - [x] Forward — Fwd: prefix, forwarded message header block, From account auto-select
101 - - [x] CC / BCC fields — togglable CC/BCC rows in compose, SMTP CC/BCC headers
102 - - [x] Multiple recipients — comma-separated To/CC/BCC, per-address SMTP validation
103 -
104 - ### Remaining
105 106 - [ ] Attachment sending — can receive but not attach to outbound
106 107 - [ ] Signatures — per-account email signature
107 108 - [ ] Drafts (real) — current drafts save as regular emails with draft@local address
@@ -117,7 +118,6 @@ v0.3.0. Audit grade A. ~762 tests.
117 118
118 119 ## Shared Code Extraction (Cross-Project)
119 120
120 - ### Remaining
121 121 - [ ] Updater UI: extract nearly-identical updater.js from GO/BB into shared module
122 122 - [ ] Theme loading: deduplicate TOML theme parser across GO/BB/AF
123 123 - [ ] Rhai host functions: deduplicate plugin runtime setup across GO/BB/AF
@@ -126,54 +126,55 @@ v0.3.0. Audit grade A. ~762 tests.
126 126
127 127 ---
128 128
129 - ## Usability Audit Remediations (2026-05-02)
129 + ## Usability Audit Remaining (2026-05-02)
130 130
131 - Audit run: `/use-fuzz GoingsOn`. Overall grade: B+. Items already tracked elsewhere are omitted.
132 -
133 - ### Bugs
134 - - [x] Fix `g v` keyboard shortcut — maps to Weekly Review but overlay says "Events" (keyboard.js:42 vs :156)
135 - - [x] Fix mobile email reply prefill — openComposeModal now accepts prefill data for reply/forward
136 -
137 - ### Discoverability (B-)
138 - - [x] Add Events pill to Time tab navigation — full view currently keyboard/URL-only
139 - - [x] Add overflow/kebab icon on hover for item rows — surfaces context menu actions without right-click
140 - - [x] Add visible Quick Add button or input in Tasks header — `q` shortcut is invisible
141 - - [x] Add "Create Event" to email right-click context menu — parity with existing "Create Task"
142 - - [x] Add one-time onboarding hints: "Press ? for shortcuts" on first launch, "Shift-click to select range" on first bulk selection
143 - - [x] Add play/timer icon to task rows for started tasks — time tracking entry point is buried
131 + ### Discoverability
144 132 - [ ] Add touch gesture hints on first mobile use (long-press, swipe, pull-to-refresh)
145 -
146 - ### Learnability (B)
147 - - [ ] Enhance welcome flow with first-action guidance or "Load sample data" option
148 - - [x] Add hint text for day planner paint-to-create: "Drag across time slots to block time"
149 - - [x] Add introductory paragraph for first weekly review explaining the workflow
150 - - [x] Add introductory content for first monthly review (title hidden by CSS, no explanation)
151 - - [x] Add brief descriptions to differentiate Start Task / Schedule Time / Track Time / Focus Mode
152 - - [x] Add 2-3 sentence explanation in sync setup panel (what SyncKit is, what syncs, E2E encryption)
153 - - [x] IMAP auto-detect server settings from email domain (Gmail, Fastmail, Outlook, Yahoo, iCloud)
154 - - [x] Rename "Pri" column header to "Priority" (only abbreviated header in task table)
155 - - [ ] Add frontend error message mapper — humanize backend error codes for toasts
156 -
157 - ### Complexity (A-)
158 - - [x] Collapse task creation form: show 4 fields (Description, Project, Priority, Due Date) + "More options" toggle for Tags, Recurrence, Estimated Time, Contact, Milestone
159 - - [x] Group time-related context menu items into "Time" submenu or consolidate Track/Focus
160 - - [ ] Use natural language date parsing for milestone target dates (currently requires YYYY-MM-DD)
161 - - [x] Add undo toast for keyboard task completion (`c` key) — matches delete undo pattern
162 -
163 - ### Feature Completeness (B-)
133 + - [x] Surface hidden features in task detail modal — subtasks, annotations, focus mode, and time tracking are only accessible via right-click context menu; add visible buttons/sections in the task detail view
134 + - [ ] Add global search / command palette (Cmd+K) — backend FTS5 exists but has no UI; search across tasks, projects, emails, contacts, events
135 + - [x] Add `g`-prefix visual feedback — pressing `g` gives no indication a key sequence is active; show a brief "Go to..." overlay listing destinations
136 + - [x] Show keyboard shortcut hints on major buttons — e.g. "[q] Quick Add", "[n] New Task", "[?] Shortcuts" as title attributes or subtle inline labels
137 + - [x] Add quick-add syntax popover — show syntax help when user types `@`, `#`, or `+` in the quick-add field
138 +
139 + ### Learnability
140 + - [x] Enhance welcome flow with first-action guidance or "Load sample data" option
141 + - [x] Add frontend error message mapper — humanize backend error codes for toasts
142 + - [x] Add real-time date parse preview — show parsed date below Due Date input as user types (e.g. "next friday" → "Friday, May 8, 2026")
143 + - [x] Add tooltip/help text for domain-specific terms — "Snooze" ("hide until a chosen date"), "Milestone" ("group tasks into project phases"), "Recurrence" ("auto-create copy after completion")
144 +
145 + ### Complexity
146 + - [x] Use natural language date parsing for milestone target dates (currently requires YYYY-MM-DD)
147 + - [x] Simplify email account setup — make OAuth the hero path; hide IMAP server/port/TLS fields behind "Advanced" toggle; auto-detect from domain; move sync interval to post-setup settings
148 + - [x] Extend undo toast window from 5s to 15s — accidental deletions are irreversible if user misses the short toast
149 + - [ ] Add batch project linking — let users select multiple tasks via checkboxes and link them all to a project in one action
150 +
151 + ### Feature Completeness
164 152 - [ ] Recurring events — table stakes for any calendar feature
165 153 - [ ] Calendar month/week grid view — even a basic one (events currently list-only)
154 + - [ ] Separate planning and visualization dashboards — day/week/month views should be distinct from weekly review; weekly review timeline is useful but planning and reviewing are different workflows
155 + - [ ] Habit tracking — daily checklist with streak/completion visualization (e.g. "gym 6/7 days", "music 5/7 days"). Recurring tasks exist but there's no "did I do this every day this week" view. Key for routine-building use cases.
156 + - [ ] Richer recurrence rules — Daily recurrence should support specifying which hours; Weekly should support specifying which days; Monthly should support specifying which weeks. E.g. "every weekday at 7am" or "weekly on Mon/Wed/Fri" instead of needing 3 separate weekly recurring items. Applies to both tasks and events.
166 157 - [ ] Time tracking reports — per-project breakdown, estimated-vs-actual, weekly/monthly summaries
167 - - [x] Manual time entry — log time retroactively, not just live timer
168 158 - [ ] Contacts export to vCard — import exists but no export (asymmetric)
169 159 - [ ] Bulk operations for contacts (tag, delete) and events (delete)
170 - - [x] Bulk "Set Project" and "Set Priority" in task bulk actions bar
171 160 - [ ] Daily review notes: persist to SQLite + sync (currently localStorage only, lost on reinstall)
172 - - [x] Monthly review: add explicit "Complete Review" action (weekly has it, monthly does not)
173 161 - [ ] Workload guardrails in day planner — warn when scheduled hours exceed target
174 162
175 163 ---
176 164
165 + ## Code Fuzz Remaining (2026-05-03)
166 +
167 + ### Serious
168 + - [x] `create_initial_snapshot` called outside sync_lock — TOCTOU gap (commands/sync.rs:269-276)
169 +
170 + ### Minor
171 + - [x] iCal DST spring-forward gap falls back to UTC interpretation (ical.rs:129). Fixed: fall back to `.latest()` for spring-forward gaps.
172 + - [x] Blob files loaded entirely into memory for sync upload (blob_sync.rs:53-61). Non-issue: attachments capped at 50 MB (attachment.rs:79), uploaded sequentially (one at a time), so worst case is ~100 MB transient (plaintext + ciphertext). XChaCha20-Poly1305 AEAD requires full plaintext for sealing.
173 + - [x] Temp HTML files from "Open in Browser" never cleaned up (commands/email.rs:345). Fixed: delayed cleanup + startup sweep.
174 + - [x] Migration FK update failures silently swallowed (migrations.rs:74). Fixed: propagate error.
175 +
176 + ---
177 +
177 178 ## Deferred
178 179
179 180 - [ ] Co-working feature: E2E encrypted project sharing (XChaCha20-Poly1305, X25519, Argon2, CRDTs, 7 phases)
@@ -203,6 +204,7 @@ migrations/sqlite/ SQLite migrations (36 files)
203 204 docs/architecture.md Crate structure and data flow
204 205 docs/audit_review.md Audit grades and action items
205 206 docs/todo/todo_mobile.md Mobile port subtodo
207 + docs/todo/todo_done.md Completed items archive
206 208 ```
207 209
208 210 - Domain purchased: goingson.app (Feb 2026)
@@ -55,7 +55,7 @@
55 55 <button class="view-toggle-btn" data-mode="board" onclick="GoingsOn.tasks.setViewMode('board')">Board</button>
56 56 </div>
57 57 <button class="btn btn-secondary" onclick="GoingsOn.keyboard.openQuickAddModal()" title="Quick add (q)">Quick Add</button>
58 - <button class="btn btn-primary" onclick="GoingsOn.tasks.openNew()">+ New Task</button>
58 + <button class="btn btn-primary" onclick="GoingsOn.tasks.openNew()" title="New task (n)">+ New Task</button>
59 59 </div>
60 60 </div>
61 61 <div id="task-bulk-actions" class="bulk-actions-bar hidden" role="toolbar" aria-label="Bulk task actions">
@@ -96,11 +96,11 @@
96 96 <select id="filter-milestone" class="filter-select" onchange="GoingsOn.tasks.applyFilters()" style="display:none">
97 97 <option value="">All Milestones</option>
98 98 </select>
99 - <label class="filter-checkbox">
99 + <label class="filter-checkbox" title="Include tasks hidden until a later date">
100 100 <input type="checkbox" id="filter-snoozed" onchange="GoingsOn.tasks.applyFilters()">
101 101 Show Snoozed
102 102 </label>
103 - <label class="filter-checkbox">
103 + <label class="filter-checkbox" title="Tasks waiting on someone else to respond">
104 104 <input type="checkbox" id="filter-waiting" onchange="GoingsOn.tasks.applyFilters()">
105 105 Waiting Only
106 106 </label>
@@ -150,7 +150,7 @@
150 150 <div id="projects-view" class="subview hidden" role="tabpanel" aria-labelledby="projects-tab">
151 151 <div class="page-header">
152 152 <h2 class="page-title">Projects</h2>
153 - <button class="btn btn-primary" onclick="GoingsOn.projects.openNew()">+ New Project</button>
153 + <button class="btn btn-primary" onclick="GoingsOn.projects.openNew()" title="New project (n)">+ New Project</button>
154 154 </div>
155 155 <div class="cards-grid" id="projects-grid">
156 156 <div class="skeleton-shimmer" aria-label="Loading projects">
@@ -310,7 +310,7 @@
310 310 <div id="events-view" class="subview hidden" role="tabpanel" aria-labelledby="events-tab">
311 311 <div class="page-header">
312 312 <h2 class="page-title">Events</h2>
313 - <button class="btn btn-primary" onclick="GoingsOn.events.openNew()">+ New Event</button>
313 + <button class="btn btn-primary" onclick="GoingsOn.events.openNew()" title="New event (n)">+ New Event</button>
314 314 </div>
315 315 <div class="events-list" id="events-list">
316 316 <div class="event-table-virtual" id="event-table" role="grid" aria-label="Events list">
@@ -362,7 +362,7 @@
362 362 <div style="display: flex; gap: 0.5rem;">
363 363 <button class="btn btn-secondary" onclick="GoingsOn.emails.openAccountsModal()">Accounts</button>
364 364 <button class="btn btn-secondary" onclick="GoingsOn.emails.markAllRead()">Mark All Read</button>
365 - <button class="btn btn-primary" onclick="GoingsOn.emails.openCompose()">+ Compose</button>
365 + <button class="btn btn-primary" onclick="GoingsOn.emails.openCompose()" title="Compose email (n)">+ Compose</button>
366 366 </div>
367 367 </div>
368 368 <div id="email-bulk-actions" class="bulk-actions-bar hidden" role="toolbar" aria-label="Bulk email actions">
@@ -400,7 +400,7 @@
400 400 <select class="filter-select" id="contacts-tag-filter" onchange="GoingsOn.contacts.filterByTag(this.value)">
401 401 <option value="">All Tags</option>
402 402 </select>
403 - <button class="btn btn-primary" onclick="GoingsOn.contacts.openNew()">+ New Contact</button>
403 + <button class="btn btn-primary" onclick="GoingsOn.contacts.openNew()" title="New contact (n)">+ New Contact</button>
404 404 </div>
405 405 </div>
406 406 <div class="cards-grid" id="contacts-grid">
@@ -242,12 +242,27 @@ async function openAboutModal() {
242 242 // ============ Populate GoingsOn.app Namespace ============
243 243
244 244 function showWelcome() {
245 + const kbdStyle = 'background: var(--bg-tertiary, #eee); padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.8rem;';
245 246 const content = `
246 247 <div style="line-height: 1.6;">
247 248 <p style="color: var(--text-secondary); margin-bottom: 1rem;">
248 249 GoingsOn brings your tasks, email, calendar, and contacts into one place.
249 250 </p>
250 251 <div style="margin-bottom: 1rem;">
252 + <h3 style="font-size: 0.9rem; margin-bottom: 0.5rem;">Get Started</h3>
253 + <div style="display: flex; flex-direction: column; gap: 0.5rem;">
254 + <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.keyboard.openQuickAdd();" style="text-align: left;">
255 + <strong>1.</strong> Create your first task &mdash; press <kbd style="${kbdStyle}">q</kbd> for quick add
256 + </button>
257 + <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.navigation.switchView('day-plan');" style="text-align: left;">
258 + <strong>2.</strong> Plan your day &mdash; drag tasks onto the timeline
259 + </button>
260 + <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();" style="text-align: left;">
261 + <strong>3.</strong> Add an email account in Settings
262 + </button>
263 + </div>
264 + </div>
265 + <div style="margin-bottom: 1rem;">
251 266 <h3 style="font-size: 0.9rem; margin-bottom: 0.25rem;">Three Tabs</h3>
252 267 <ul style="color: var(--text-secondary); font-size: 0.875rem; padding-left: 1.25rem; margin: 0;">
253 268 <li><strong>Work</strong> &mdash; tasks &amp; projects</li>
@@ -256,7 +271,7 @@ function showWelcome() {
256 271 </ul>
257 272 </div>
258 273 <p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem;">
259 - Press <kbd style="background: var(--bg-tertiary, #eee); padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.8rem;">?</kbd> anytime for keyboard shortcuts.
274 + Press <kbd style="${kbdStyle}">?</kbd> anytime for keyboard shortcuts.
260 275 </p>
261 276 </div>
262 277 <div class="form-actions">
@@ -197,10 +197,10 @@ const pendingUndos = new Map();
197 197 * @param {Object} options - Options
198 198 * @param {Function} options.onConfirm - Called when timeout expires (perform actual delete)
199 199 * @param {Function} options.onUndo - Called when user clicks undo (restore item)
200 - * @param {number} options.timeout - Undo window in milliseconds (default: 5000)
200 + * @param {number} options.timeout - Undo window in milliseconds (default: 15000)
201 201 * @returns {string} - Undo ID (can be used to cancel programmatically)
202 202 */
203 - function showUndoToast(message, { onConfirm, onUndo, timeout = 5000 }) {
203 + function showUndoToast(message, { onConfirm, onUndo, timeout = 15000 }) {
204 204 const undoId = `undo-${Date.now()}-${Math.random().toString(36).slice(2)}`;
205 205
206 206 const toast = document.createElement('div');
@@ -57,6 +57,11 @@
57 57 return `<option value="${escAttr(opt.value)}" ${selected}>${esc(opt.label)}</option>`;
58 58 }).join('');
59 59
60 + // For new accounts, check if server fields have been pre-filled (edit mode always shows them)
61 + const hasServerValues = isEdit || !!(values.imapServer || values.smtpServer);
62 + const advancedExpanded = hasServerValues ? 'expanded' : '';
63 + const advancedHidden = hasServerValues ? '' : 'hidden';
64 +
60 65 return `
61 66 <form id="${formId}" onsubmit="${escAttr(onSubmit)}">
62 67 <div class="form-group">
@@ -66,30 +71,11 @@
66 71 <div class="form-group">
67 72 <label class="form-label" for="${idPrefix}-email">Email Address</label>
68 73 <input type="email" class="form-input" id="${idPrefix}-email" name="email_address" required placeholder="you@example.com" value="${escAttr(values.emailAddress || '')}">
69 - </div>
70 - <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
71 - <div class="form-group">
72 - <label class="form-label" for="${idPrefix}-imap-server">IMAP Server</label>
73 - <input type="text" class="form-input" id="${idPrefix}-imap-server" name="imap_server" required placeholder="imap.example.com" value="${escAttr(values.imapServer || '')}">
74 - </div>
75 - <div class="form-group">
76 - <label class="form-label" for="${idPrefix}-imap-port">IMAP Port</label>
77 - <input type="number" class="form-input" id="${idPrefix}-imap-port" name="imap_port" required value="${values.imapPort || 993}">
78 - </div>
79 - </div>
80 - <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
81 - <div class="form-group">
82 - <label class="form-label" for="${idPrefix}-smtp-server">SMTP Server</label>
83 - <input type="text" class="form-input" id="${idPrefix}-smtp-server" name="smtp_server" required placeholder="smtp.example.com" value="${escAttr(values.smtpServer || '')}">
84 - </div>
85 - <div class="form-group">
86 - <label class="form-label" for="${idPrefix}-smtp-port">SMTP Port</label>
87 - <input type="number" class="form-input" id="${idPrefix}-smtp-port" name="smtp_port" required value="${values.smtpPort || 587}">
88 - </div>
74 + <div id="${idPrefix}-detect-status" style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;"></div>
89 75 </div>
90 76 <div class="form-group">
91 77 <label class="form-label" for="${idPrefix}-username">Username</label>
92 - <input type="text" class="form-input" id="${idPrefix}-username" name="username" required placeholder="your username" value="${escAttr(values.username || '')}">
78 + <input type="text" class="form-input" id="${idPrefix}-username" name="username" required placeholder="Usually your email address" value="${escAttr(values.username || '')}">
93 79 </div>
94 80 <div class="form-group">
95 81 <label class="form-label" for="${idPrefix}-password">${esc(passwordLabel)}</label>
@@ -102,20 +88,43 @@
102 88 ${esc(archiveHint)}
103 89 </div>
104 90 </div>
105 - <div class="form-group">
106 - <label class="form-label" for="${idPrefix}-sync-interval">Auto-sync Interval</label>
107 - <select class="form-select" id="${idPrefix}-sync-interval" name="sync_interval_minutes">
108 - ${syncOptionsHtml}
109 - </select>
110 - <div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">
111 - Automatically check for new emails at this interval.
91 + <button type="button" class="form-more-toggle ${advancedExpanded}" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden');">Advanced settings</button>
92 + <div class="${advancedHidden}">
93 + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
94 + <div class="form-group">
95 + <label class="form-label" for="${idPrefix}-imap-server">IMAP Server</label>
96 + <input type="text" class="form-input" id="${idPrefix}-imap-server" name="imap_server" required placeholder="imap.example.com" value="${escAttr(values.imapServer || '')}">
97 + </div>
98 + <div class="form-group">
99 + <label class="form-label" for="${idPrefix}-imap-port">IMAP Port</label>
100 + <input type="number" class="form-input" id="${idPrefix}-imap-port" name="imap_port" required value="${values.imapPort || 993}">
101 + </div>
102 + </div>
103 + <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
104 + <div class="form-group">
105 + <label class="form-label" for="${idPrefix}-smtp-server">SMTP Server</label>
106 + <input type="text" class="form-input" id="${idPrefix}-smtp-server" name="smtp_server" required placeholder="smtp.example.com" value="${escAttr(values.smtpServer || '')}">
107 + </div>
108 + <div class="form-group">
109 + <label class="form-label" for="${idPrefix}-smtp-port">SMTP Port</label>
110 + <input type="number" class="form-input" id="${idPrefix}-smtp-port" name="smtp_port" required value="${values.smtpPort || 587}">
111 + </div>
112 + </div>
113 + <div class="form-group">
114 + <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
115 + <input type="checkbox" id="${idPrefix}-use-tls" name="use_tls" ${values.useTls !== false ? 'checked' : ''}>
116 + <span>Use TLS/SSL</span>
117 + </label>
118 + </div>
119 + <div class="form-group">
120 + <label class="form-label" for="${idPrefix}-sync-interval">Auto-sync Interval</label>
121 + <select class="form-select" id="${idPrefix}-sync-interval" name="sync_interval_minutes">
122 + ${syncOptionsHtml}
123 + </select>
124 + <div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">
125 + Automatically check for new emails at this interval.
126 + </div>
112 127 </div>
113 - </div>
114 - <div class="form-group">
115 - <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
116 - <input type="checkbox" id="${idPrefix}-use-tls" name="use_tls" ${values.useTls !== false ? 'checked' : ''}>
117 - <span>Use TLS/SSL</span>
118 - </label>
119 128 </div>
120 129 <div class="form-actions">
121 130 <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.openAccountsModal()">Cancel</button>
@@ -157,13 +166,25 @@
157 166 const emailEl = document.getElementById(`${idPrefix}-email`);
158 167 if (!emailEl) return;
159 168
160 - emailEl.addEventListener('change', () => {
169 + let lastDetectedDomain = null;
170 +
171 + const detect = () => {
161 172 const email = emailEl.value.trim();
162 173 const domain = email.split('@')[1]?.toLowerCase();
163 - if (!domain) return;
174 + if (!domain || domain === lastDetectedDomain) return;
164 175
165 176 const settings = PROVIDER_SETTINGS[domain];
166 - if (!settings) return;
177 + const statusEl = document.getElementById(`${idPrefix}-detect-status`);
178 +
179 + if (!settings) {
180 + if (statusEl && domain.includes('.')) {
181 + statusEl.textContent = 'Unknown provider — fill in server details under Advanced settings';
182 + statusEl.style.color = 'var(--text-secondary)';
183 + }
184 + return;
185 + }
186 +
187 + lastDetectedDomain = domain;
167 188
168 189 const imapEl = document.getElementById(`${idPrefix}-imap-server`);
169 190 const imapPortEl = document.getElementById(`${idPrefix}-imap-port`);
@@ -172,14 +193,22 @@
172 193 const usernameEl = document.getElementById(`${idPrefix}-username`);
173 194 const archiveEl = document.getElementById(`${idPrefix}-archive-folder`);
174 195
175 - // Only auto-fill if fields are empty or at defaults
176 - if (imapEl && !imapEl.value) imapEl.value = settings.imap;
196 + // Auto-fill server fields
197 + if (imapEl && (!imapEl.value || imapEl.value === 'imap.example.com')) imapEl.value = settings.imap;
177 198 if (imapPortEl && (imapPortEl.value === '993' || !imapPortEl.value)) imapPortEl.value = settings.imapPort;
178 - if (smtpEl && !smtpEl.value) smtpEl.value = settings.smtp;
199 + if (smtpEl && (!smtpEl.value || smtpEl.value === 'smtp.example.com')) smtpEl.value = settings.smtp;
179 200 if (smtpPortEl && (smtpPortEl.value === '587' || !smtpPortEl.value)) smtpPortEl.value = settings.smtpPort;
180 201 if (usernameEl && !usernameEl.value) usernameEl.value = email;
181 202 if (archiveEl && (archiveEl.value === 'Archive' || !archiveEl.value)) archiveEl.value = settings.archive;
182 - });
203 +
204 + if (statusEl) {
205 + statusEl.textContent = `Detected ${domain} — server settings auto-filled`;
206 + statusEl.style.color = 'var(--accent-green)';
207 + }
208 + };
209 +
210 + emailEl.addEventListener('input', detect);
211 + emailEl.addEventListener('change', detect);
183 212 }
184 213
185 214 // ============ Email Accounts ============
@@ -54,6 +54,7 @@
54 54 ? new Date(event.start_time).toISOString().slice(0, 16)
55 55 : localISOTime,
56 56 transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
57 + onInput: GoingsOn.utils.dateParsePreview,
57 58 },
58 59 {
59 60 name: 'end_time',
@@ -64,6 +65,7 @@
64 65 ? new Date(event.end_time).toISOString().slice(0, 16)
65 66 : '',
66 67 transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
68 + onInput: GoingsOn.utils.dateParsePreview,
67 69 validate: (v, data) => {
68 70 if (v && data.start_time && new Date(v) < new Date(data.start_time)) {
69 71 return 'End time must be after start time';
@@ -22,6 +22,7 @@
22 22 * @property {Array<{value: string, label: string, selected?: boolean}>} [options] - Options for select fields
23 23 * @property {*} [value] - Default/current value
24 24 * @property {string} [hint] - Help text below field
25 + * @property {Function} [onInput] - Called on input event with (value, previewEl) for live preview
25 26 * @property {Object} [gridColumn] - CSS grid column (e.g., '1fr 1fr' for row)
26 27 */
27 28
@@ -57,6 +58,16 @@ function openFormModal(config) {
57 58 const defaultSubmitLabel = isEdit ? 'Save Changes' : `Create ${entityType.charAt(0).toUpperCase() + entityType.slice(1)}`;
58 59
59 60 // Build fields HTML
61 + const hasExtended = fields.some(f => f.extended);
62 + // Auto-expand if any extended field has a non-default value
63 + const extendedHasValues = fields.some(f => {
64 + if (!f.extended) return false;
65 + const v = presetData[f.name] ?? f.value ?? '';
66 + if (f.type === 'select') return v !== '' && v !== 'None' && v !== 'Medium';
67 + return v !== '';
68 + });
69 +
70 + let inExtended = false;
60 71 const fieldsHtml = fields.map(field => {
61 72 const value = presetData[field.name] ?? field.value ?? '';
62 73 const required = field.required ? 'required' : '';
@@ -98,15 +109,26 @@ function openFormModal(config) {
98 109 }
99 110
100 111 const hintHtml = field.hint ? `<div class="form-hint" style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">${esc(field.hint)}</div>` : '';
112 + const previewHtml = field.onInput ? `<div id="${inputId}-preview" class="form-hint" style="font-size: 0.75rem; color: var(--accent-primary); margin-top: 0.25rem;"></div>` : '';
101 113
102 - return `
114 + let prefix = '';
115 + if (hasExtended && field.extended && !inExtended) {
116 + inExtended = true;
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'}">`;
119 + }
120 +
121 + const groupHtml = `
103 122 <div class="form-group">
104 123 <label class="form-label" for="${inputId}">${esc(field.label)}</label>
105 124 ${inputHtml}
106 125 ${hintHtml}
126 + ${previewHtml}
107 127 </div>
108 128 `;
109 - }).join('');
129 +
130 + return prefix + groupHtml;
131 + }).join('') + (inExtended ? '</div>' : '');
110 132
111 133 const content = `
112 134 <form id="${formId}">
@@ -211,6 +233,20 @@ function openFormModal(config) {
211 233 }, { signal: modalAbortController.signal });
212 234 }
213 235
236 + // Wire up live preview handlers (e.g., date parse preview)
237 + for (const field of fields) {
238 + if (!field.onInput) continue;
239 + const inputId = `${formId}-${field.name}`;
240 + const el = document.getElementById(inputId);
241 + const previewEl = document.getElementById(`${inputId}-preview`);
242 + if (el && previewEl) {
243 + const handler = () => field.onInput(el.value, previewEl);
244 + el.addEventListener('input', handler, { signal: modalAbortController.signal });
245 + // Run once on open to show preview for pre-filled values
246 + if (el.value) handler();
247 + }
248 + }
249 +
214 250 }, 50);
215 251 }
216 252
@@ -39,7 +39,7 @@
39 39 't': { action: () => GoingsOn.navigation.switchView('tasks'), description: 'Go to Tasks' },
40 40 'e': { action: () => GoingsOn.navigation.switchView('emails'), description: 'Go to Emails' },
41 41 'p': { action: () => GoingsOn.navigation.switchView('projects'), description: 'Go to Projects' },
42 - 'v': { action: () => GoingsOn.navigation.switchView('weekly-review'), description: 'Go to Week View' },
42 + 'v': { action: () => GoingsOn.navigation.switchView('events'), description: 'Go to Events' },
43 43 'd': { action: () => GoingsOn.navigation.switchView('day-plan'), description: 'Go to Day Plan' },
44 44 'c': { action: () => GoingsOn.navigation.switchView('contacts'), description: 'Go to Contacts' },
45 45 'w': { action: () => GoingsOn.navigation.switchView('weekly-review'), description: 'Go to Weekly Review' },
@@ -86,6 +86,7 @@
86 86 sequence[e.key].action();
87 87 }
88 88 pendingKey = null;
89 + dismissPendingKeyHint();
89 90 return;
90 91 }
91 92
@@ -114,13 +115,57 @@
114 115 // This is a two-key sequence starter
115 116 e.preventDefault();
116 117 pendingKey = e.key;
118 + showPendingKeyHint(e.key, shortcut);
117 119 pendingKeyTimeout = setTimeout(() => {
118 120 pendingKey = null;
121 + dismissPendingKeyHint();
119 122 }, 1000);
120 123 }
121 124 }
122 125 }
123 126
127 + // ============ Pending Key Hint ============
128 +
129 + /**
130 + * Show a small hint when a two-key sequence is started (e.g. pressing 'g').
131 + * Lists the available follow-up keys so the user knows what to press next.
132 + */
133 + function showPendingKeyHint(key, sequence) {
134 + dismissPendingKeyHint();
135 + const hint = document.createElement('div');
136 + hint.id = 'pending-key-hint';
137 +
138 + const kbdStyle = 'background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;padding:0.125rem 0.4rem;font-family:inherit;font-size:0.875rem;';
139 + const destinations = Object.entries(sequence)
140 + .map(([k, v]) => `<kbd style="${kbdStyle}">${k}</kbd> ${GoingsOn.utils.escapeHtml(v.description.replace('Go to ', ''))}`)
141 + .join('&nbsp;&nbsp;&middot;&nbsp;&nbsp;');
142 +
143 + hint.innerHTML = `<span style="opacity:0.7;">Go to:</span> ${destinations}`;
144 + hint.style.cssText = `
145 + position: fixed;
146 + bottom: 2rem;
147 + left: 50%;
148 + transform: translateX(-50%);
149 + padding: 0.5rem 1rem;
150 + background: var(--bg-card);
151 + color: var(--text-primary);
152 + border: var(--border-width) solid var(--border-color);
153 + border-radius: var(--radius-md);
154 + box-shadow: var(--shadow-brutal);
155 + z-index: 10000;
156 + font-size: 0.875rem;
157 + white-space: nowrap;
158 + animation: toastSlideIn 0.15s ease;
159 + `;
160 + hint.querySelectorAll; // force layout
161 + document.body.appendChild(hint);
162 + }
163 +
164 + function dismissPendingKeyHint() {
165 + const hint = document.getElementById('pending-key-hint');
166 + if (hint) hint.remove();
167 + }
168 +
124 169 // ============ Shortcuts Overlay ============
125 170
126 171 function showShortcutsOverlay() {
@@ -249,10 +294,14 @@
249 294 <div class="form-group">
250 295 <label class="form-label">Quick Add Task</label>
251 296 <input type="text" class="form-input" name="text" required
252 - placeholder="Task description @project #tag +priority due:tomorrow"
253 - autofocus>
254 - <div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.5rem;">
255 - Syntax: @project #tag +H/M/L (priority) due:tomorrow/today/+3d
297 + placeholder="Fix login bug @backend #urgent +H due:friday"
298 + autofocus
299 + oninput="GoingsOn.keyboard._onQuickAddInput(this)">
300 + <div id="quick-add-syntax" style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.5rem; line-height: 1.6;">
301 + <span data-token="@" style="margin-right: 0.75rem;"><kbd style="font-size:0.7rem;padding:0.1rem 0.3rem;border:1px solid var(--border-color);border-radius:3px;background:var(--bg-secondary);">@</kbd> project</span>
302 + <span data-token="#" style="margin-right: 0.75rem;"><kbd style="font-size:0.7rem;padding:0.1rem 0.3rem;border:1px solid var(--border-color);border-radius:3px;background:var(--bg-secondary);">#</kbd> tag</span>
303 + <span data-token="+" style="margin-right: 0.75rem;"><kbd style="font-size:0.7rem;padding:0.1rem 0.3rem;border:1px solid var(--border-color);border-radius:3px;background:var(--bg-secondary);">+</kbd> H/M/L priority</span>
304 + <span data-token="due:" style="margin-right: 0.75rem;"><kbd style="font-size:0.7rem;padding:0.1rem 0.3rem;border:1px solid var(--border-color);border-radius:3px;background:var(--bg-secondary);">due:</kbd> tomorrow, friday, +3d</span>
256 305 </div>
257 306 </div>
258 307 <div class="form-actions">
@@ -265,6 +314,31 @@
265 314 }
266 315
267 316 /**
317 + * Highlight the syntax hint matching what the user is currently typing.
318 + * @param {HTMLInputElement} input - The quick-add text input
319 + */
320 + function onQuickAddInput(input) {
321 + const syntaxEl = document.getElementById('quick-add-syntax');
322 + if (!syntaxEl) return;
323 + const val = input.value;
324 + // Find the token the cursor is currently inside
325 + const cursor = input.selectionStart || val.length;
326 + const beforeCursor = val.slice(0, cursor);
327 + const lastWord = beforeCursor.split(/\s/).pop() || '';
328 + const activeToken = lastWord.startsWith('@') ? '@'
329 + : lastWord.startsWith('#') ? '#'
330 + : lastWord.startsWith('+') ? '+'
331 + : lastWord.startsWith('due:') ? 'due:'
332 + : null;
333 +
334 + for (const span of syntaxEl.querySelectorAll('[data-token]')) {
335 + const isActive = span.dataset.token === activeToken;
336 + span.style.opacity = activeToken ? (isActive ? '1' : '0.4') : '1';
337 + span.style.fontWeight = isActive ? '600' : 'normal';
338 + }
339 + }
340 +
341 + /**
268 342 * Submit the quick-add task form.
269 343 * @param {Event} e - Form submit event
270 344 */
@@ -474,6 +548,7 @@
474 548 openQuickAdd: openQuickAddModal,
475 549 submitQuickAdd: submitQuickAdd,
476 550 resetSelection: resetSelectedItemIndex,
551 + _onQuickAddInput: onQuickAddInput,
477 552 };
478 553
479 554 })();
@@ -431,7 +431,7 @@
431 431 fields: [
432 432 { name: 'name', type: 'text', label: 'Name', required: true, value: '' },
433 433 { name: 'description', type: 'textarea', label: 'Description', value: '' },
434 - { name: 'targetDate', type: 'text', label: 'Target Date (YYYY-MM-DD)', value: '', placeholder: '2026-03-01' },
434 + { name: 'targetDate', type: 'text', label: 'Target Date', value: '', placeholder: 'next friday, 2026-03-01...', transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview },
435 435 ],
436 436 onSubmit: async (data) => {
437 437 await GoingsOn.ui.apiCall(
@@ -467,7 +467,7 @@
467 467 fields: [
468 468 { name: 'name', type: 'text', label: 'Name', required: true, value: m.name },
469 469 { name: 'description', type: 'textarea', label: 'Description', value: m.description },
470 - { name: 'targetDate', type: 'text', label: 'Target Date (YYYY-MM-DD)', value: m.targetDate || '', placeholder: '2026-03-01' },
470 + { name: 'targetDate', type: 'text', label: 'Target Date', value: m.targetDate || '', placeholder: 'next friday, 2026-03-01...', transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview },
471 471 { name: 'status', type: 'select', label: 'Status', value: m.status, options: [
472 472 { value: 'open', label: 'Open' },
473 473 { value: 'completed', label: 'Completed' },
@@ -45,7 +45,9 @@
45 45 </div>
46 46 </div>`;
47 47
48 - GoingsOn.ui.openModal(`Snooze ${itemType === 'task' ? 'Task' : 'Email'}`, optionsHtml);
48 + const label = itemType === 'task' ? 'Task' : 'Email';
49 + const hintHtml = `<p style="font-size: 0.8rem; color: var(--text-secondary); margin: 0 0 0.75rem 0;">Hide this ${label.toLowerCase()} and bring it back at the chosen time.</p>`;
50 + GoingsOn.ui.openModal(`Snooze ${label}`, hintHtml + optionsHtml);
49 51 }
50 52
51 53 /**
@@ -124,6 +124,7 @@
124 124 placeholder: 'tomorrow, friday 3pm, 2026-12-25...',
125 125 value: dueValue,
126 126 transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
127 + onInput: GoingsOn.utils.dateParsePreview,
127 128 },
128 129 {
129 130 name: 'tags',
@@ -132,16 +133,19 @@
132 133 placeholder: 'work, urgent, meeting',
133 134 value: tagsValue,
134 135 transform: (v) => GoingsOn.utils.normalizeTags(v).join(', '),
136 + extended: true,
135 137 },
136 138 {
137 139 name: 'recurrence',
138 140 type: 'select',
139 141 label: 'Recurrence',
142 + hint: 'Completing a recurring task auto-creates the next occurrence',
140 143 options: RECURRENCE_OPTIONS.map(r => ({
141 144 ...r,
142 145 selected: task?.recurrence === r.value,
143 146 })),
144 147 value: task?.recurrence || 'None',
148 + extended: true,
145 149 },
146 150 {
147 151 name: 'estimated_minutes',
@@ -149,6 +153,7 @@
149 153 label: 'Estimated Time (minutes)',
150 154 placeholder: 'e.g. 30, 60, 120',
151 155 value: task?.estimatedMinutes || '',
156 + extended: true,
152 157 },
153 158 {
154 159 name: 'contact_id',
@@ -159,15 +164,18 @@
159 164 ...getContactOptions(task?.contactId),
160 165 ],
161 166 value: task?.contactId || '',
167 + extended: true,
162 168 },
163 169 {
164 170 name: 'milestone_id',
165 171 type: 'select',
166 172 label: 'Milestone',
173 + hint: 'Group tasks into project phases — milestones are managed per project',
167 174 options: [
168 175 { value: '', label: 'No Milestone' },
169 176 ],
170 177 value: task?.milestoneId || '',
178 + extended: true,
171 179 }
172 180 );
173 181
@@ -181,15 +181,23 @@
181 181 <div style="margin-bottom: 1rem; max-height: 300px; overflow-y: auto;">
182 182 ${subtasksList}
183 183 </div>
184 - <form onsubmit="GoingsOn.tasks.addSubtask(event, '${taskId}')" style="display: flex; gap: 0.5rem;">
184 + <form onsubmit="GoingsOn.tasks.addSubtask(event, '${escAttr(taskId)}')" style="display: flex; gap: 0.5rem;">
185 185 <input type="text" class="form-input" name="subtask_text" placeholder="Add a subtask..." style="flex: 1;">
186 186 <button type="submit" class="btn btn-primary">Add</button>
187 187 </form>
188 188 <div style="margin-top: 0.5rem;">
189 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.tasks.openLinkTaskPicker('${taskId}')" style="width: 100%;">Link Another Task</button>
189 + <button type="button" class="btn btn-secondary" onclick="GoingsOn.tasks.openLinkTaskPicker('${escAttr(taskId)}')" style="width: 100%;">Link Another Task</button>
190 190 </div>
191 - <div class="form-actions" style="margin-top: 1rem;">
192 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.tasks.openActions('${taskId}')">Task Actions</button>
191 + <div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border-color);">
192 + <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.tasks.openEdit('${escAttr(taskId)}')" title="Edit task fields">Edit</button>
193 + <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.tasks.addAnnotation('${escAttr(taskId)}')" title="Add a note to this task">Note</button>
194 + <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.timeTracking.startTimer('${escAttr(taskId)}')" title="Start live timer">Track Time</button>
195 + <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.focusTimer.start('${escAttr(taskId)}')" title="Pomodoro-style focus session">Focus</button>
196 + <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.dayPlan.openScheduleTaskModal('${escAttr(taskId)}')" title="Block time on day planner">Schedule</button>
197 + <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.attachments.openPanel('${escAttr(taskId)}', null)" title="Manage file attachments">Files</button>
198 + </div>
199 + <div class="form-actions" style="margin-top: 0.75rem;">
200 + <button type="button" class="btn btn-secondary" onclick="GoingsOn.tasks.openActions('${escAttr(taskId)}')">All Actions</button>
193 201 <div style="flex: 1;"></div>
194 202 <button type="button" class="btn btn-secondary" onclick="GoingsOn.tasks.closeSubtasksAndRefresh()">Close</button>
195 203 </div>
@@ -36,18 +36,78 @@ function escapeAttr(str) {
36 36 }
37 37
38 38 /**
39 - * Extract error message from various error types
39 + * Human-friendly prefixes for machine-readable API error codes.
40 + * Backend sends structured ApiError { code, message, details }.
41 + */
42 + const ERROR_CODE_LABELS = {
43 + NOT_FOUND: 'Not found',
44 + VALIDATION_ERROR: 'Invalid input',
45 + DATABASE_ERROR: 'Database error',
46 + BAD_REQUEST: 'Bad request',
47 + AUTH_ERROR: 'Authentication failed',
48 + PARSE_ERROR: 'Could not parse input',
49 + INTERNAL_ERROR: 'Something went wrong',
50 + CONFLICT: 'Conflict',
51 + EXTERNAL_SERVICE_ERROR: 'Service error',
52 + };
53 +
54 + /**
55 + * Extract error message from various error types.
56 + * Handles: plain strings, Error objects, and structured ApiError objects
57 + * from the Tauri backend ({ code, message, details }).
40 58 * @param {Error|string|object} err - Error object or string
41 59 * @param {string} fallback - Fallback message if extraction fails
42 60 * @returns {string} - Human-readable error message
43 61 */
44 62 function getErrorMessage(err, fallback) {
45 - // Tauri returns errors as strings, not Error objects
46 - if (typeof err === 'string') return err;
63 + // Tauri returns errors as strings (legacy) or JSON strings (ApiError)
64 + if (typeof err === 'string') {
65 + // Try to parse as JSON ApiError
66 + try {
67 + const parsed = JSON.parse(err);
68 + if (parsed && parsed.code && parsed.message) {
69 + return humanizeApiError(parsed);
70 + }
71 + } catch (_) { /* not JSON, use as-is */ }
72 + return err;
73 + }
74 +
75 + // Structured ApiError object (code + message)
76 + if (err && err.code && err.message && typeof err.code === 'string') {
77 + return humanizeApiError(err);
78 + }
79 +
80 + // Standard Error object
47 81 if (err && err.message) return err.message;
82 +
48 83 return fallback || 'An error occurred';
49 84 }
50 85
86 + /**
87 + * Convert a structured ApiError into a user-friendly string.
88 + * Strips internal prefixes like "Failed to ..." and UUID resource IDs.
89 + * @param {{code: string, message: string, details?: object}} apiErr
90 + * @returns {string}
91 + */
92 + function humanizeApiError(apiErr) {
93 + const label = ERROR_CODE_LABELS[apiErr.code];
94 + let msg = apiErr.message;
95 +
96 + // Strip UUID suffixes from not-found messages (e.g. "task not found: 550e8400-...")
97 + if (apiErr.code === 'NOT_FOUND' && apiErr.details?.resource) {
98 + const resource = apiErr.details.resource;
99 + const capitalized = resource.charAt(0).toUpperCase() + resource.slice(1);
100 + return `${capitalized} not found`;
101 + }
102 +
103 + // For database/internal errors, hide the raw detail and show the friendly label
104 + if (apiErr.code === 'DATABASE_ERROR' || apiErr.code === 'INTERNAL_ERROR') {
105 + return label || msg;
106 + }
107 +
108 + return label ? `${label}: ${msg}` : msg;
109 + }
110 +
51 111 // ============ Input Validation ============
52 112
53 113 /**
@@ -539,6 +599,39 @@ function normalizeTags(tagString) {
539 599 });
540 600 }
541 601
602 + // ============ Date Parse Preview ============
603 +
604 + /**
605 + * Live preview callback for natural language date fields.
606 + * Use as onInput in form field definitions.
607 + * @param {string} value - Current input value
608 + * @param {HTMLElement} previewEl - Element to show parsed result
609 + */
610 + function dateParsePreview(value, previewEl) {
611 + if (!value || !value.trim()) {
612 + previewEl.textContent = '';
613 + return;
614 + }
615 + const parsed = parseNaturalDate(value);
616 + if (parsed) {
617 + const d = new Date(parsed);
618 + const display = d.toLocaleDateString(undefined, {
619 + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
620 + hour: 'numeric', minute: '2-digit',
621 + });
622 + previewEl.textContent = display;
623 + previewEl.style.color = 'var(--accent-primary)';
624 + } else {
625 + // Only show "not recognized" if it doesn't look like an ISO date being typed
626 + if (value.trim().length > 2) {
627 + previewEl.textContent = 'Date not recognized';
628 + previewEl.style.color = 'var(--text-secondary)';
629 + } else {
630 + previewEl.textContent = '';
631 + }
632 + }
633 + }
634 +
542 635 // ============ Populate GoingsOn.utils Namespace ============
543 636
544 637 GoingsOn.utils = {
@@ -558,6 +651,7 @@ GoingsOn.utils = {
558 651
559 652 // Natural date parsing
560 653 parseNaturalDate,
654 + dateParsePreview,
561 655
562 656 // Tag normalization
563 657 normalizeTags,