max / goingson
13 files changed,
+400 insertions,
-129 deletions
| @@ -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 — 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 — 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> — tasks & 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(' · '); | |
| 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, |