Skip to main content

max / goingson

Surface the features that were already there Two sweeps of folded discoverability problems. Nothing here is a new capability; every item is an existing one users could not find. Right-click menus, search syntax, and a Getting Started entry join the ? overlay. The day plan gets a one-time walkthrough naming its three invisible gestures, behind a new hint_dayplan config key (Local: the gestures differ per device, so having seen them on one says nothing about the next). The New Task form points at Quick Add, the tasks filter bar points at the command palette, and the palette itself now completes is:, priority:, type: and in: from the token under the cursor. Settings sections carry a one-line description in the sidebar, and the sync indicator says "Syncing..." or "Sync error" in words. That handler was only recoloring a dot, so both states existed as a color and nothing else. type:contact was supported by the parser but missing from its module doc, and so would have been missing from both new help surfaces.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-28 14:57 UTC
Signed with PGP, not checked
Commit: 26d6e8077db8da2cfa50f2008f284eb5980dd1a1
Parent: bcb4c91
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">&larr; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>&#8984;</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">&copy; 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 }