max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+385 insertions,
-15 deletions
| @@ -133,6 +133,7 @@ | |||
| 133 | 133 | </label> | |
| 134 | 134 | <div class="filter-actions"> | |
| 135 | 135 | <span id="task-count" class="filter-count" aria-live="polite"></span> | |
| 136 | + | <button class="btn btn-link" data-act="search.open" title="Search with is:overdue, tag:work, in:Project and more">Advanced search</button> | |
| 136 | 137 | <button class="btn btn-link" data-act="tasks.clearFilters">Clear filters</button> | |
| 137 | 138 | </div> | |
| 138 | 139 | </div> | |
| @@ -570,14 +571,14 @@ | |||
| 570 | 571 | <button class="btn-link mb-section settings-back" data-act="settings.goBack" aria-label="Close settings">← Close</button> | |
| 571 | 572 | <h2 class="sr-only" id="settings-overlay-title">Settings</h2> | |
| 572 | 573 | <div class="settings-nav-items"> | |
| 573 | - | <button class="settings-nav-item active" data-section="appearance" data-act="settings.showSection" data-a1="appearance">Appearance</button> | |
| 574 | - | <button class="settings-nav-item" data-section="notifications" data-act="settings.showSection" data-a1="notifications">Notifications</button> | |
| 575 | - | <button class="settings-nav-item" data-section="email" data-act="settings.showSection" data-a1="email">Email Accounts</button> | |
| 576 | - | <button class="settings-nav-item" data-section="planning" data-act="settings.showSection" data-a1="planning">Planning & Review</button> | |
| 577 | - | <button class="settings-nav-item" data-section="sync" data-act="settings.showSection" data-a1="sync">Cloud Sync</button> | |
| 578 | - | <button class="settings-nav-item" data-section="sharing" data-act="settings.showSection" data-a1="sharing">Sharing</button> | |
| 579 | - | <button class="settings-nav-item" data-section="data" data-act="settings.showSection" data-a1="data">Import & Export</button> | |
| 580 | - | <button class="settings-nav-item" data-section="about" data-act="settings.showSection" data-a1="about">About</button> | |
| 574 | + | <button class="settings-nav-item active" data-section="appearance" data-act="settings.showSection" data-a1="appearance"><span class="settings-nav-label">Appearance</span><span class="settings-nav-desc">Theme</span></button> | |
| 575 | + | <button class="settings-nav-item" data-section="notifications" data-act="settings.showSection" data-a1="notifications"><span class="settings-nav-label">Notifications</span><span class="settings-nav-desc">Event lead time</span></button> | |
| 576 | + | <button class="settings-nav-item" data-section="email" data-act="settings.showSection" data-a1="email"><span class="settings-nav-label">Email Accounts</span><span class="settings-nav-desc">Connected mailboxes</span></button> | |
| 577 | + | <button class="settings-nav-item" data-section="planning" data-act="settings.showSection" data-a1="planning"><span class="settings-nav-label">Planning & Review</span><span class="settings-nav-desc">Work hours, nudges</span></button> | |
| 578 | + | <button class="settings-nav-item" data-section="sync" data-act="settings.showSection" data-a1="sync"><span class="settings-nav-label">Cloud Sync</span><span class="settings-nav-desc">Devices, encryption</span></button> | |
| 579 | + | <button class="settings-nav-item" data-section="sharing" data-act="settings.showSection" data-a1="sharing"><span class="settings-nav-label">Sharing</span><span class="settings-nav-desc">Groups, shared projects</span></button> | |
| 580 | + | <button class="settings-nav-item" data-section="data" data-act="settings.showSection" data-a1="data"><span class="settings-nav-label">Import & Export</span><span class="settings-nav-desc">Move data in and out</span></button> | |
| 581 | + | <button class="settings-nav-item" data-section="about" data-act="settings.showSection" data-a1="about"><span class="settings-nav-label">About</span><span class="settings-nav-desc">Version, updates, support</span></button> | |
| 581 | 582 | </div> | |
| 582 | 583 | </nav> | |
| 583 | 584 | <div class="settings-content" id="settings-content"> |
| @@ -28,8 +28,10 @@ | |||
| 28 | 28 | /// Preferences worth carrying across a user's devices are `Synced`; per-device | |
| 29 | 29 | /// state is `Local`. `ui_mode` (desktop/mobile form factor) is `Local` and also | |
| 30 | 30 | /// the one paint-critical key, kept mirrored in `localStorage` for the pre-paint | |
| 31 | - | /// bootstrap. `welcomed` (first-run) and `hint_shortcuts` (a keyboard-hint | |
| 32 | - | /// dismissal) are per-device. | |
| 31 | + | /// bootstrap. `welcomed` (first-run), `hint_shortcuts` (a keyboard-hint | |
| 32 | + | /// dismissal) and `hint_dayplan` (the first-visit day-plan walkthrough) are | |
| 33 | + | /// per-device: the gestures those teach differ between a desktop and a tablet, | |
| 34 | + | /// so having seen them on one device says nothing about the next. | |
| 33 | 35 | pub const CONFIG: ConfigSpec = ConfigSpec::new( | |
| 34 | 36 | "user_config", | |
| 35 | 37 | &[ | |
| @@ -44,6 +46,7 @@ | |||
| 44 | 46 | ("ui_mode", Posture::Local), | |
| 45 | 47 | ("welcomed", Posture::Local), | |
| 46 | 48 | ("hint_shortcuts", Posture::Local), | |
| 49 | + | ("hint_dayplan", Posture::Local), | |
| 47 | 50 | // The wam endpoint: the reachable address for a tailnet service differs | |
| 48 | 51 | // per machine, so syncing it would hand one device another's endpoint. | |
| 49 | 52 | // Its bearer token is a secret and lives in the OS keychain, not here. | |
| @@ -74,7 +77,7 @@ | |||
| 74 | 77 | "{key} is a preference and should sync" | |
| 75 | 78 | ); | |
| 76 | 79 | } | |
| 77 | - | for key in ["ui_mode", "welcomed", "hint_shortcuts"] { | |
| 80 | + | for key in ["ui_mode", "welcomed", "hint_shortcuts", "hint_dayplan"] { | |
| 78 | 81 | assert!( | |
| 79 | 82 | !CONFIG.is_synced(key), | |
| 80 | 83 | "{key} is per-device and must not sync" |
| @@ -18,7 +18,7 @@ | |||
| 18 | 18 | //! - `priority:medium` / `priority:m` - Medium priority | |
| 19 | 19 | //! - `priority:low` / `priority:l` - Low priority | |
| 20 | 20 | //! - `in:ProjectName` - Filter to specific project by name | |
| 21 | - | //! - `type:task` / `type:email` / `type:event` / `type:project` - Filter result types | |
| 21 | + | //! - `type:task` / `type:email` / `type:event` / `type:project` / `type:contact` - Filter result types | |
| 22 | 22 | //! - `tag:name` - Include items with tag | |
| 23 | 23 | //! - `-tag:name` - Exclude items with tag | |
| 24 | 24 | //! - `after:date` / `from:date` - Items due on or after date |
| @@ -2630,7 +2630,7 @@ | |||
| 2630 | 2630 | background: var(--surface-raised); | |
| 2631 | 2631 | border-radius: var(--radius-md); | |
| 2632 | 2632 | padding: var(--gap-pane); | |
| 2633 | - | max-width: 600px; | |
| 2633 | + | max-width: 720px; | |
| 2634 | 2634 | max-height: 80vh; | |
| 2635 | 2635 | overflow-y: auto; | |
| 2636 | 2636 | } | |
| @@ -2661,7 +2661,39 @@ | |||
| 2661 | 2661 | } | |
| 2662 | 2662 | .shortcuts-close { | |
| 2663 | 2663 | margin-top: var(--gap-section); | |
| 2664 | - | text-align: center; | |
| 2664 | + | display: flex; | |
| 2665 | + | justify-content: center; | |
| 2666 | + | gap: var(--gap-peer); | |
| 2667 | + | } | |
| 2668 | + | .shortcuts-note { | |
| 2669 | + | color: var(--content-secondary); | |
| 2670 | + | font-size: var(--font-size-sm); | |
| 2671 | + | margin-bottom: var(--gap-peer); | |
| 2672 | + | } | |
| 2673 | + | /* Reference rows in the ? overlay: a fixed left term and wrapping prose on the | |
| 2674 | + | right. Unlike .shortcut-row, the description is not pushed to the far edge -- | |
| 2675 | + | these values are long enough that a right-aligned run would be unreadable. */ | |
| 2676 | + | .syntax-row { | |
| 2677 | + | display: flex; | |
| 2678 | + | align-items: baseline; | |
| 2679 | + | gap: var(--gap-bound); | |
| 2680 | + | margin-bottom: var(--gap-peer); | |
| 2681 | + | font-size: var(--font-size-sm); | |
| 2682 | + | } | |
| 2683 | + | .syntax-row > span:last-child { color: var(--content-secondary); } | |
| 2684 | + | .syntax-row .syntax-key { | |
| 2685 | + | color: var(--content); | |
| 2686 | + | font-weight: 600; | |
| 2687 | + | flex-shrink: 0; | |
| 2688 | + | } | |
| 2689 | + | .syntax-row kbd { | |
| 2690 | + | background: var(--surface-overlay); | |
| 2691 | + | border: var(--border-width) solid var(--border); | |
| 2692 | + | border-radius: var(--radius-xs); | |
| 2693 | + | padding: var(--step-tight) var(--step-base); | |
| 2694 | + | font-family: inherit; | |
| 2695 | + | font-size: var(--font-size-sm); | |
| 2696 | + | flex-shrink: 0; | |
| 2665 | 2697 | } | |
| 2666 | 2698 | .shortcut-row { | |
| 2667 | 2699 | display: flex; | |
| @@ -3437,6 +3469,15 @@ | |||
| 3437 | 3469 | color: var(--content); | |
| 3438 | 3470 | } | |
| 3439 | 3471 | ||
| 3472 | + | /* A one-line pointer from a form to a faster route to the same result. Sits | |
| 3473 | + | between the last field and the form actions. */ | |
| 3474 | + | .form-alt-path { | |
| 3475 | + | color: var(--content-secondary); | |
| 3476 | + | font-size: var(--font-size-sm); | |
| 3477 | + | margin-top: var(--gap-section); | |
| 3478 | + | } | |
| 3479 | + | .form-alt-path .btn-link { padding: 0; font-size: inherit; } | |
| 3480 | + | ||
| 3440 | 3481 | /* 24. Responsive - Large Screens & Tablet */ | |
| 3441 | 3482 | ||
| 3442 | 3483 | /* Wide desktop, intra-desktop responsive (only desktop UI gets these). */ | |
| @@ -4757,6 +4798,17 @@ | |||
| 4757 | 4798 | border-radius: 0; | |
| 4758 | 4799 | border-left: 2px solid transparent; | |
| 4759 | 4800 | } | |
| 4801 | + | .settings-nav-label { display: block; } | |
| 4802 | + | /* One-line "what lives here" under each section name. Suppressed in mobile | |
| 4803 | + | mode, where the sidebar collapses to a horizontal chip row. */ | |
| 4804 | + | .settings-nav-desc { | |
| 4805 | + | display: block; | |
| 4806 | + | font-size: var(--font-size-xs); | |
| 4807 | + | font-weight: 400; | |
| 4808 | + | color: var(--content-muted); | |
| 4809 | + | margin-top: var(--step-hair); | |
| 4810 | + | line-height: var(--line-height-normal); | |
| 4811 | + | } | |
| 4760 | 4812 | .settings-nav-item:hover { background: var(--hover-surface); color: var(--content); } | |
| 4761 | 4813 | .settings-nav-item.active { | |
| 4762 | 4814 | color: var(--content); | |
| @@ -9519,6 +9571,7 @@ | |||
| 9519 | 9571 | .ui-mode-mobile .settings-nav-items { flex-direction: row; flex-wrap: wrap; gap: var(--gap-peer); } | |
| 9520 | 9572 | .ui-mode-mobile .settings-nav-item { padding: var(--gap-peer) var(--gap-group); border-left: none; border-radius: var(--radius-sm); } | |
| 9521 | 9573 | .ui-mode-mobile .settings-nav-item.active { border-left-color: transparent; } | |
| 9574 | + | .ui-mode-mobile .settings-nav-desc { display: none; } | |
| 9522 | 9575 | .ui-mode-mobile .settings-content { padding: var(--gap-group); } | |
| 9523 | 9576 | ||
| 9524 | 9577 | ||
| @@ -9655,6 +9708,38 @@ | |||
| 9655 | 9708 | padding-bottom: var(--gap-bound); | |
| 9656 | 9709 | } | |
| 9657 | 9710 | ||
| 9711 | + | /* Completions for the filter prefix under the cursor. Sits above the active | |
| 9712 | + | filter tags: suggestions are what you could type, tags are what took effect. */ | |
| 9713 | + | .cp-suggestions { | |
| 9714 | + | display: flex; | |
| 9715 | + | align-items: center; | |
| 9716 | + | gap: var(--gap-peer); | |
| 9717 | + | padding: 0 var(--gap-group); | |
| 9718 | + | flex-wrap: wrap; | |
| 9719 | + | } | |
| 9720 | + | .cp-suggestions:not(:empty) { | |
| 9721 | + | padding-top: var(--gap-peer); | |
| 9722 | + | padding-bottom: var(--gap-bound); | |
| 9723 | + | } | |
| 9724 | + | ||
| 9725 | + | .cp-suggestion { | |
| 9726 | + | font-size: 0.75rem; | |
| 9727 | + | padding: var(--step-hair) var(--step-base); | |
| 9728 | + | background: var(--surface-overlay); | |
| 9729 | + | border: var(--border-width) solid var(--border); | |
| 9730 | + | border-radius: var(--radius-sm); | |
| 9731 | + | color: var(--content-secondary); | |
| 9732 | + | cursor: pointer; | |
| 9733 | + | font-family: inherit; | |
| 9734 | + | } | |
| 9735 | + | .cp-suggestion:hover { color: var(--content); background: var(--hover-surface); } | |
| 9736 | + | .cp-suggestion--first { border-color: var(--action); color: var(--content); } | |
| 9737 | + | ||
| 9738 | + | .cp-suggestion-hint { | |
| 9739 | + | font-size: 0.75rem; | |
| 9740 | + | color: var(--content-muted); | |
| 9741 | + | } | |
| 9742 | + | ||
| 9658 | 9743 | .cp-filter-tag { | |
| 9659 | 9744 | font-size: 0.75rem; | |
| 9660 | 9745 | padding: var(--step-hair) var(--step-base); |
| @@ -228,19 +228,32 @@ | |||
| 228 | 228 | }); | |
| 229 | 229 | }); | |
| 230 | 230 | ||
| 231 | - | // Cloud sync: status changed (syncing/idle/error) | |
| 231 | + | // Cloud sync: status changed (syncing/idle/error). The dot carries the state | |
| 232 | + | // as color; the label and tooltip carry it as words, so a transient sync or a | |
| 233 | + | // failure is legible without hovering a colored dot and guessing. Idle hands | |
| 234 | + | // off to refreshSyncIndicator, which knows the fuller resting text ("Synced | |
| 235 | + | // 3m ago", held changes, encryption setup). | |
| 232 | 236 | listen('sync:status-changed', (event) => { | |
| 233 | 237 | const dot = document.getElementById('sync-dot'); | |
| 234 | 238 | const indicator = document.getElementById('sync-indicator'); | |
| 239 | + | const label = document.getElementById('sync-label'); | |
| 235 | 240 | if (!dot || !indicator) return; | |
| 236 | 241 | indicator.classList.remove('hidden'); | |
| 237 | 242 | dot.className = 'sync-dot'; | |
| 243 | + | ||
| 238 | 244 | if (event.payload === 'syncing') { | |
| 239 | 245 | dot.classList.add('syncing'); | |
| 246 | + | if (label) label.textContent = 'Syncing...'; | |
| 247 | + | indicator.setAttribute('title', 'Cloud Sync: syncing now'); | |
| 248 | + | indicator.setAttribute('aria-label', 'Cloud Sync: syncing now'); | |
| 240 | 249 | } else if (event.payload === 'error') { | |
| 241 | 250 | dot.classList.add('error'); | |
| 251 | + | if (label) label.textContent = 'Sync error'; | |
| 252 | + | indicator.setAttribute('title', 'Cloud Sync: last sync failed. Open Cloud Sync for details.'); | |
| 253 | + | indicator.setAttribute('aria-label', 'Cloud Sync: last sync failed'); | |
| 242 | 254 | } else { | |
| 243 | 255 | dot.classList.add('connected'); | |
| 256 | + | GoingsOn.settings?.refreshSyncIndicator?.(); | |
| 244 | 257 | } | |
| 245 | 258 | }); | |
| 246 | 259 | } |
| @@ -28,6 +28,7 @@ | |||
| 28 | 28 | 'goingson.uiMode': 'ui_mode', | |
| 29 | 29 | 'go-welcomed': 'welcomed', | |
| 30 | 30 | 'go-hint-shortcuts': 'hint_shortcuts', | |
| 31 | + | 'go-hint-dayplan': 'hint_dayplan', | |
| 31 | 32 | 'goingson-event-lead-minutes': 'event_lead_minutes', | |
| 32 | 33 | 'goingson-plan-nudges': 'plan_nudges', | |
| 33 | 34 | 'goingson-review-nudges': 'review_nudges', |
| @@ -52,6 +52,8 @@ | |||
| 52 | 52 | ||
| 53 | 53 | // Initialize mobile swipe navigation (once per load) | |
| 54 | 54 | if (!swipeNavCleanup) initSwipeDayNav(); | |
| 55 | + | ||
| 56 | + | maybeShowWalkthrough(); | |
| 55 | 57 | } catch (err) { | |
| 56 | 58 | console.error('Failed to load day planning:', err); | |
| 57 | 59 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load day planning'), 'error', { | |
| @@ -61,6 +63,56 @@ | |||
| 61 | 63 | } | |
| 62 | 64 | } | |
| 63 | 65 | ||
| 66 | + | /** | |
| 67 | + | * On the first visit to the day plan, name the three gestures that make the | |
| 68 | + | * timeline usable. None of them is visible in the surface itself: painting, | |
| 69 | + | * dragging in an unscheduled task, and the review pane all look like inert | |
| 70 | + | * layout until someone tells you otherwise. Once per device, and never | |
| 71 | + | * blocking -- the view is already loaded behind it. | |
| 72 | + | */ | |
| 73 | + | async function maybeShowWalkthrough() { | |
| 74 | + | // The view can render before the config cache is filled; reading early | |
| 75 | + | // would misread every launch as a first visit. | |
| 76 | + | await GoingsOn.config.ready; | |
| 77 | + | if (GoingsOn.config.get('go-hint-dayplan')) return; | |
| 78 | + | // The first-run welcome owns the very first launch. This waits for a | |
| 79 | + | // deliberate visit rather than stacking a second modal behind it. | |
| 80 | + | if (!GoingsOn.config.get('go-welcomed')) return; | |
| 81 | + | // Nor can it land on top of a modal the user already opened. | |
| 82 | + | if (!document.getElementById('modal-overlay')?.classList.contains('hidden')) return; | |
| 83 | + | ||
| 84 | + | const isTouch = !!GoingsOn.touch?.isTouchDevice; | |
| 85 | + | const steps = isTouch | |
| 86 | + | ? [ | |
| 87 | + | 'Tap an empty slot on the timeline to block out time.', | |
| 88 | + | 'Tap a task in the sidebar to schedule it onto the day.', | |
| 89 | + | 'Swipe the timeline left or right to move between days.', | |
| 90 | + | ] | |
| 91 | + | : [ | |
| 92 | + | 'Drag down an empty stretch of the timeline to block out time.', | |
| 93 | + | 'Drag a task from the sidebar onto the timeline to schedule it.', | |
| 94 | + | 'Use <kbd>[</kbd> and <kbd>]</kbd> to move between days.', | |
| 95 | + | ]; | |
| 96 | + | ||
| 97 | + | const content = ` | |
| 98 | + | <div class="welcome-panel"> | |
| 99 | + | <p class="welcome-intro"> | |
| 100 | + | The timeline is the day itself, hour by hour. Unscheduled tasks wait in the sidebar until you give them a time. | |
| 101 | + | </p> | |
| 102 | + | <div class="welcome-section"> | |
| 103 | + | <ul class="welcome-tabs-list"> | |
| 104 | + | ${steps.map(s => `<li>${s}</li>`).join('')} | |
| 105 | + | </ul> | |
| 106 | + | </div> | |
| 107 | + | <p class="welcome-hint">At the end of the day, the review pane below the timeline records what actually happened.</p> | |
| 108 | + | </div> | |
| 109 | + | <div class="form-actions"> | |
| 110 | + | <button class="btn btn-primary" data-act="ui.markSeenThen" data-a1="go-hint-dayplan">Got it</button> | |
| 111 | + | </div> | |
| 112 | + | `; | |
| 113 | + | GoingsOn.ui.openModal('Planning your day', content); | |
| 114 | + | } | |
| 115 | + | ||
| 64 | 116 | function renderTimeline() { | |
| 65 | 117 | GoingsOn.dayPlanRender.renderTimeline(GoingsOn.state.dayPlanDate, GoingsOn.state.dayPlanData); | |
| 66 | 118 | wireTouchInteractions(); |
| @@ -225,7 +225,36 @@ | |||
| 225 | 225 | </div> | |
| 226 | 226 | </div> | |
| 227 | 227 | ||
| 228 | + | <h3 class="shortcuts-group-heading-spaced">RIGHT-CLICK MENUS</h3> | |
| 229 | + | <p class="shortcuts-note">Right-click any row or card for its full action list. On a touch device, use the row's kebab button instead.</p> | |
| 230 | + | <div class="shortcuts-grid"> | |
| 231 | + | <div> | |
| 232 | + | <div class="syntax-row"><span class="syntax-key">Task row</span> <span>Edit, Start, Complete, Subtasks, Add Note, Set Milestone, Snooze, Schedule Time, Track Time, Focus Mode, Delete</span></div> | |
| 233 | + | <div class="syntax-row"><span class="syntax-key">Email row</span> <span>Open, Mark Read/Unread, Archive, Create Task, Create Event, Snooze, Delete</span></div> | |
| 234 | + | </div> | |
| 235 | + | <div> | |
| 236 | + | <div class="syntax-row"><span class="syntax-key">Event row</span> <span>Open, Edit, Delete</span></div> | |
| 237 | + | <div class="syntax-row"><span class="syntax-key">Project card</span> <span>Open, Edit, Add Task, Add Event, Share, Delete</span></div> | |
| 238 | + | </div> | |
| 239 | + | </div> | |
| 240 | + | ||
| 241 | + | <h3 class="shortcuts-group-heading-spaced">SEARCH SYNTAX</h3> | |
| 242 | + | <p class="shortcuts-note">Type these in the command palette (<kbd>⌘</kbd> <kbd>K</kbd>) to narrow results. Combine as many as you like.</p> | |
| 243 | + | <div class="shortcuts-grid"> | |
| 244 | + | <div> | |
| 245 | + | <div class="syntax-row"><kbd>is:</kbd> <span>overdue, today, tomorrow, thisweek, snoozed, pending, started, completed, waiting</span></div> | |
| 246 | + | <div class="syntax-row"><kbd>priority:</kbd> <span>high, medium, low</span></div> | |
| 247 | + | <div class="syntax-row"><kbd>type:</kbd> <span>task, email, event, project, contact</span></div> | |
| 248 | + | </div> | |
| 249 | + | <div> | |
| 250 | + | <div class="syntax-row"><kbd>in:</kbd> <span>a project name</span></div> | |
| 251 | + | <div class="syntax-row"><kbd>tag:</kbd> <span>a tag name (<kbd>-tag:</kbd> to exclude)</span></div> | |
| 252 | + | <div class="syntax-row"><kbd>after:</kbd> <kbd>before:</kbd> <span>a due date</span></div> | |
| 253 | + | </div> | |
| 254 | + | </div> | |
| 255 | + | ||
| 228 | 256 | <div class="shortcuts-close"> | |
| 257 | + | <button class="btn btn-secondary" data-act="keyboard.openGettingStarted">Getting Started</button> | |
| 229 | 258 | <button class="btn btn-secondary" data-act="keyboard.closeShortcuts">Close (Esc)</button> | |
| 230 | 259 | </div> | |
| 231 | 260 | </div> | |
| @@ -248,6 +277,15 @@ | |||
| 248 | 277 | } | |
| 249 | 278 | } | |
| 250 | 279 | ||
| 280 | + | /** | |
| 281 | + | * Close the overlay and re-run the first-run welcome. The welcome is | |
| 282 | + | * otherwise a one-shot the user can never get back to. | |
| 283 | + | */ | |
| 284 | + | function openGettingStarted() { | |
| 285 | + | closeShortcutsOverlay(); | |
| 286 | + | GoingsOn.app.showWelcome(); | |
| 287 | + | } | |
| 288 | + | ||
| 251 | 289 | /** | |
| 252 | 290 | * Toggle the keyboard shortcuts overlay open/closed. | |
| 253 | 291 | */ | |
| @@ -562,6 +600,7 @@ | |||
| 562 | 600 | showShortcuts: showShortcutsOverlay, | |
| 563 | 601 | closeShortcuts: closeShortcutsOverlay, | |
| 564 | 602 | toggleShortcuts: toggleKeyboardShortcutsOverlay, | |
| 603 | + | openGettingStarted, | |
| 565 | 604 | openQuickAdd: openQuickAddModal, | |
| 566 | 605 | submitQuickAdd: submitQuickAdd, | |
| 567 | 606 | resetSelection: resetSelectedItemIndex, |
| @@ -13,6 +13,8 @@ | |||
| 13 | 13 | let debounceTimer = null; | |
| 14 | 14 | let selectedIndex = -1; | |
| 15 | 15 | let currentResults = []; | |
| 16 | + | // Completions for the filter prefix under the cursor, rebuilt on every input. | |
| 17 | + | let suggestions = []; | |
| 16 | 18 | ||
| 17 | 19 | // Overlay Creation | |
| 18 | 20 | ||
| @@ -32,6 +34,7 @@ | |||
| 32 | 34 | autocomplete="off" spellcheck="false"> | |
| 33 | 35 | <kbd class="cp-esc">Esc</kbd> | |
| 34 | 36 | </div> | |
| 37 | + | <div id="cp-suggestions" class="cp-suggestions"></div> | |
| 35 | 38 | <div id="cp-filters" class="cp-filters"></div> | |
| 36 | 39 | <div id="cp-results" class="cp-results"> | |
| 37 | 40 | <div class="cp-hint"> | |
| @@ -46,6 +49,12 @@ | |||
| 46 | 49 | const input = overlay.querySelector('#cp-input'); | |
| 47 | 50 | input.addEventListener('input', onInput); | |
| 48 | 51 | input.addEventListener('keydown', onKeydown); | |
| 52 | + | // The caret can move without the value changing (click, arrow key), and | |
| 53 | + | // the offered completions depend on which token it lands in. | |
| 54 | + | input.addEventListener('click', renderSuggestions); | |
| 55 | + | input.addEventListener('keyup', (e) => { | |
| 56 | + | if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) renderSuggestions(); | |
| 57 | + | }); | |
| 49 | 58 | ||
| 50 | 59 | document.body.appendChild(overlay); | |
| 51 | 60 | return overlay; | |
| @@ -70,6 +79,8 @@ | |||
| 70 | 79 | </div> | |
| 71 | 80 | `; | |
| 72 | 81 | overlay.querySelector('#cp-filters').innerHTML = ''; | |
| 82 | + | overlay.querySelector('#cp-suggestions').innerHTML = ''; | |
| 83 | + | suggestions = []; | |
| 73 | 84 | } | |
| 74 | 85 | ||
| 75 | 86 | function close() { | |
| @@ -82,10 +93,120 @@ | |||
| 82 | 93 | return overlay && !overlay.classList.contains('hidden'); | |
| 83 | 94 | } | |
| 84 | 95 | ||
| 96 | + | // Filter Prefix Suggestions | |
| 97 | + | ||
| 98 | + | // Mirrors the closed vocabularies in crates/core/src/search_parser.rs. Keep | |
| 99 | + | // in step with that file: a value listed here that the parser does not know | |
| 100 | + | // is silently dropped from the query, which reads as a broken filter. | |
| 101 | + | const PREFIX_VALUES = { | |
| 102 | + | 'is:': ['overdue', 'today', 'tomorrow', 'thisweek', 'snoozed', 'pending', 'started', 'completed', 'waiting'], | |
| 103 | + | 'priority:': ['high', 'medium', 'low'], | |
| 104 | + | 'type:': ['task', 'email', 'event', 'project', 'contact'], | |
| 105 | + | }; | |
| 106 | + | ||
| 107 | + | // Prefixes whose value is free text, so there is nothing to enumerate. They | |
| 108 | + | // still get a hint line, because the point is telling the user what comes | |
| 109 | + | // after the colon. | |
| 110 | + | const PREFIX_HINTS = { | |
| 111 | + | 'tag:': 'a tag name', | |
| 112 | + | '-tag:': 'a tag name to exclude', | |
| 113 | + | 'after:': 'a date, e.g. friday or 2026-08-01', | |
| 114 | + | 'from:': 'a date, e.g. friday or 2026-08-01', | |
| 115 | + | 'before:': 'a date, e.g. friday or 2026-08-01', | |
| 116 | + | 'to:': 'a date, e.g. friday or 2026-08-01', | |
| 117 | + | }; | |
| 118 | + | ||
| 119 | + | /** Project names to offer after `in:`. The parser matches partially, so a | |
| 120 | + | * multi-word name completes to its first word and still resolves. */ | |
| 121 | + | function projectSuggestions(partial) { | |
| 122 | + | const projects = GoingsOn.state?.projects || []; | |
| 123 | + | return projects | |
| 124 | + | .filter(p => p.name && p.name.toLowerCase().startsWith(partial)) | |
| 125 | + | .slice(0, 8) | |
| 126 | + | .map(p => ({ value: p.name.split(/\s+/)[0], label: p.name })); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /** | |
| 130 | + | * The filter token the cursor currently sits in, if it is one. | |
| 131 | + | * @returns {{prefix: string, partial: string, start: number, end: number}|null} | |
| 132 | + | */ | |
| 133 | + | function tokenAtCursor(input) { | |
| 134 | + | const value = input.value; | |
| 135 | + | const cursor = input.selectionStart ?? value.length; | |
| 136 | + | let start = value.lastIndexOf(' ', cursor - 1) + 1; | |
| 137 | + | let end = value.indexOf(' ', cursor); | |
| 138 | + | if (end === -1) end = value.length; | |
| 139 | + | const token = value.slice(start, end); | |
| 140 | + | ||
| 141 | + | const colon = token.indexOf(':'); | |
| 142 | + | if (colon === -1) return null; | |
| 143 | + | const prefix = token.slice(0, colon + 1).toLowerCase(); | |
| 144 | + | if (!(prefix in PREFIX_VALUES) && !(prefix in PREFIX_HINTS) && prefix !== 'in:') return null; | |
| 145 | + | return { prefix, partial: token.slice(colon + 1).toLowerCase(), start, end }; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /** | |
| 149 | + | * Render completions for the filter prefix under the cursor. Clears itself | |
| 150 | + | * when the cursor is not in a filter token. | |
| 151 | + | */ | |
| 152 | + | function renderSuggestions() { | |
| 153 | + | const container = overlay.querySelector('#cp-suggestions'); | |
| 154 | + | const input = overlay.querySelector('#cp-input'); | |
| 155 | + | const token = tokenAtCursor(input); | |
| 156 | + | suggestions = []; | |
| 157 | + | ||
| 158 | + | if (!token) { | |
| 159 | + | container.innerHTML = ''; | |
| 160 | + | return; | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | if (token.prefix === 'in:') { | |
| 164 | + | suggestions = projectSuggestions(token.partial); | |
| 165 | + | } else if (token.prefix in PREFIX_VALUES) { | |
| 166 | + | suggestions = PREFIX_VALUES[token.prefix] | |
| 167 | + | .filter(v => v.startsWith(token.partial)) | |
| 168 | + | .map(v => ({ value: v, label: v })); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | if (suggestions.length === 0) { | |
| 172 | + | const hint = PREFIX_HINTS[token.prefix] | |
| 173 | + | || (token.prefix === 'in:' ? 'a project name' : null); | |
| 174 | + | container.innerHTML = hint | |
| 175 | + | ? `<span class="cp-suggestion-hint"><kbd>${esc(token.prefix)}</kbd> takes ${esc(hint)}</span>` | |
| 176 | + | : ''; | |
| 177 | + | return; | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | const chips = suggestions.map((s, i) => { | |
| 181 | + | const first = i === 0 ? ' cp-suggestion--first' : ''; | |
| 182 | + | return `<button type="button" class="cp-suggestion${first}" data-act="search._applySuggestion" data-a1="${escAttr(String(i))}">${esc(token.prefix)}${esc(s.label)}</button>`; | |
| 183 | + | }).join(''); | |
| 184 | + | container.innerHTML = `${chips}<span class="cp-suggestion-hint">Tab to complete</span>`; | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /** | |
| 188 | + | * Replace the token under the cursor with a completed filter and re-search. | |
| 189 | + | * @param {number|string} index - Index into the current suggestion list | |
| 190 | + | */ | |
| 191 | + | function applySuggestion(index) { | |
| 192 | + | const i = Number(index); | |
| 193 | + | const input = overlay.querySelector('#cp-input'); | |
| 194 | + | const token = tokenAtCursor(input); | |
| 195 | + | if (!token || !suggestions[i]) return; | |
| 196 | + | ||
| 197 | + | const completed = `${token.prefix}${suggestions[i].value} `; | |
| 198 | + | input.value = input.value.slice(0, token.start) + completed + input.value.slice(token.end); | |
| 199 | + | const caret = token.start + completed.length; | |
| 200 | + | input.focus(); | |
| 201 | + | input.setSelectionRange(caret, caret); | |
| 202 | + | onInput(); | |
| 203 | + | } | |
| 204 | + | ||
| 85 | 205 | // Search | |
| 86 | 206 | ||
| 87 | 207 | function onInput() { | |
| 88 | 208 | clearTimeout(debounceTimer); | |
| 209 | + | renderSuggestions(); | |
| 89 | 210 | const query = overlay.querySelector('#cp-input').value.trim(); | |
| 90 | 211 | ||
| 91 | 212 | if (!query) { | |
| @@ -203,6 +324,14 @@ | |||
| 203 | 324 | return; | |
| 204 | 325 | } | |
| 205 | 326 | ||
| 327 | + | // Tab completes the first offered filter value. Only when there is one, | |
| 328 | + | // so Tab keeps its normal meaning otherwise. | |
| 329 | + | if (e.key === 'Tab' && !e.shiftKey && suggestions.length > 0) { | |
| 330 | + | e.preventDefault(); | |
| 331 | + | applySuggestion(0); | |
| 332 | + | return; | |
| 333 | + | } | |
| 334 | + | ||
| 206 | 335 | if (e.key === 'ArrowDown') { | |
| 207 | 336 | e.preventDefault(); | |
| 208 | 337 | if (currentResults.length > 0) { | |
| @@ -280,6 +409,7 @@ | |||
| 280 | 409 | isOpen, | |
| 281 | 410 | _hover: hoverResult, | |
| 282 | 411 | _select: selectResult, | |
| 412 | + | _applySuggestion: applySuggestion, | |
| 283 | 413 | }; | |
| 284 | 414 | ||
| 285 | 415 | })(); |
| @@ -348,6 +348,11 @@ | |||
| 348 | 348 | updateCheckOnLaunch = prefs.updateCheckOnLaunch !== false; | |
| 349 | 349 | } catch (_) {} | |
| 350 | 350 | ||
| 351 | + | // No physical keyboard, no reason to offer the shortcuts sheet. | |
| 352 | + | const shortcutsButton = GoingsOn.touch?.isTouchDevice | |
| 353 | + | ? '' | |
| 354 | + | : '<button class="btn btn-secondary" data-act="settings.openKeyboardShortcuts">Keyboard Shortcuts</button>'; | |
| 355 | + | ||
| 351 | 356 | container.innerHTML = ` | |
| 352 | 357 | <div class="settings-section"> | |
| 353 | 358 | <h3 class="settings-heading">About GoingsOn</h3> | |
| @@ -375,6 +380,11 @@ | |||
| 375 | 380 | </label> | |
| 376 | 381 | </div> | |
| 377 | 382 | ||
| 383 | + | <div class="settings-actions-row"> | |
| 384 | + | <button class="btn btn-secondary" data-act="settings.openGettingStarted">Getting Started</button> | |
| 385 | + | ${shortcutsButton} | |
| 386 | + | </div> | |
| 387 | + | ||
| 378 | 388 | <p class="settings-desc-block about-copyright">© 2026 Make Creative, LLC</p> | |
| 379 | 389 | </div> | |
| 380 | 390 | `; | |
| @@ -406,11 +416,29 @@ | |||
| 406 | 416 | ||
| 407 | 417 | // Populate GoingsOn Namespace | |
| 408 | 418 | ||
| 419 | + | /** | |
| 420 | + | * Re-run the first-run welcome from Settings > About. Closes settings first | |
| 421 | + | * so the welcome is not a modal stacked on an overlay, and so Escape has one | |
| 422 | + | * unambiguous thing to close. | |
| 423 | + | */ | |
| 424 | + | function openGettingStarted() { | |
| 425 | + | goBack(); | |
| 426 | + | GoingsOn.app.showWelcome(); | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | /** Open the keyboard shortcuts overlay from Settings > About. */ | |
| 430 | + | function openKeyboardShortcuts() { | |
| 431 | + | goBack(); | |
| 432 | + | GoingsOn.keyboard.showShortcuts(); | |
| 433 | + | } | |
| 434 | + | ||
| 409 | 435 | GoingsOn.settings = { | |
| 410 | 436 | open: openSettings, | |
| 411 | 437 | load: loadSettings, | |
| 412 | 438 | showSection, | |
| 413 | 439 | goBack, | |
| 440 | + | openGettingStarted, | |
| 441 | + | openKeyboardShortcuts, | |
| 414 | 442 | onEventLeadTimeChange, | |
| 415 | 443 | onWorkHoursChange, | |
| 416 | 444 | setUpdateCheckOnLaunch, |
| @@ -277,6 +277,22 @@ | |||
| 277 | 277 | } | |
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | + | /** | |
| 281 | + | * Pointer from the full New Task form to Quick Add. Quick Add is bound to | |
| 282 | + | * `q` and otherwise has no entry point a user discovers from this form, so | |
| 283 | + | * the fast path stays invisible to anyone who reaches for the button. | |
| 284 | + | * @returns {string} HTML for openFormModal's extraContent slot | |
| 285 | + | */ | |
| 286 | + | function quickAddPointer() { | |
| 287 | + | const key = GoingsOn.touch?.isTouchDevice ? '' : ' (<kbd>q</kbd>)'; | |
| 288 | + | return ` | |
| 289 | + | <p class="form-alt-path"> | |
| 290 | + | In a hurry? <button type="button" class="btn-link" data-act="keyboard.openQuickAdd">Use Quick Add syntax</button>${key} | |
| 291 | + | to type project, tag, priority and due date in one line. | |
| 292 | + | </p> | |
| 293 | + | `; | |
| 294 | + | } | |
| 295 | + | ||
| 280 | 296 | function openNew() { | |
| 281 | 297 | GoingsOn.ui.openFormModal({ | |
| 282 | 298 | title: 'New Task', | |
| @@ -284,6 +300,7 @@ | |||
| 284 | 300 | isEdit: false, | |
| 285 | 301 | fields: getTaskFormFields(), | |
| 286 | 302 | onSubmit: create, | |
| 303 | + | extraContent: quickAddPointer(), | |
| 287 | 304 | }); | |
| 288 | 305 | setupMilestoneSelect('task', 'new'); | |
| 289 | 306 | GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence'); | |
| @@ -300,6 +317,7 @@ | |||
| 300 | 317 | isEdit: false, | |
| 301 | 318 | fields: getTaskFormFields(null, projectId), | |
| 302 | 319 | onSubmit: create, | |
| 320 | + | extraContent: quickAddPointer(), | |
| 303 | 321 | }); | |
| 304 | 322 | setupMilestoneSelect('task', 'new', projectId); | |
| 305 | 323 | } |