Calendar Quick Add (part 2)
Part 2 of the Calendar Quick Add page: why the parser is regex-first with the AI as a rare fallback, why the preview shows more than one row only when the input is genuinely ambiguous, and the repo-aware code map underneath the tab.
What it is
This is part 2 of the Calendar Quick Add page. It covers why the tab behaves the way it does, decision by decision, and then the code map underneath it: the parser, the time-zone conversion, the calendar picker, the fallback AI call, the IPC channels and the daily cost cap.
Where to find it
There is nothing new to open here. Every decision below is about the same one surface: the Calendar tab of the Quick Launch composer, opened with Ctrl+Space and described step by step on the parent page. The daily fallback cap and the saved default calendar are the only two pieces of state it keeps.
How it behaves
Design decisions worth knowing
Text-first, with a Repeat checkbox as a fallback (2026-05-24 pivot; checkbox added 2026-07-06) — the original UI had a separate free-text Repeat field, but the inline recurrence detector already handles "every weekday" / "yearly" / "every other Tuesday" from the same line. Two free-text fields where one sufficed produced inconsistent results (an Item and a Repeat that disagreed) and was more UI to read, so the free-text Repeat field was dropped — the typed line is the source of truth for the title and the RRULE. What came back later is different in kind: a small structured Repeat checkbox + frequency picker (not a second text box) that stays out of the way until you need to force a repeat the text didn't imply. It mirrors any parsed recurrence, so it can never silently disagree with the preview (see #11).
Regex first, LLM only on miss — and the AI is meant to be rare (~<1%) — after the Phase 2 layer extensions (date-anywhere scan covering "tomorrow 10am birthday", "10am tomorrow gym", "meeting at 3 today", plus slang anchors like tonite/tmrw/2morrow), the anchored regex handles the overwhelming majority of inputs with $0 spend. Haiku is the fallback for the long-tail prose the regex misses. The "AI is thinking…" pill exists so when you do hit that ~1% case you understand why the parse took an extra second — not because the feature is slow, but because your phrasing was unusual.
Multi-row preview only when the input is genuinely ambiguous (2026-05-25 tightening) — "Friday" is ambiguous (is it the whole day or a morning thing?) so Omniscio surfaces an all-day row and a 9 AM timed alternative. But "Birthday tomorrow at 2pm" is not ambiguous — the user typed the exact time — so the parser emits a single row instead of the pre-2026-05-25 trio that also showed an all-day alternative on the same date and a 30-minute look-alike that rendered identically to the 1-hour default. The dedupe layer applies to the LLM fallback path too so the same look-alike collapse holds when Haiku produces the rows.
Fixed window; tall previews scroll (changed 2026-08-01) — the Control Space window is one fixed 720×660 size for every tab; the old grow-to-fit resize was deleted because any live window grow exposed a strip the compositor cleared white for a frame (the recurring flash bug — entrance-animation contract I21). A preview taller than the window scrolls, and the Cancel/Create footer is pinned OUTSIDE the scroll region (a
flex-shrink-0sibling), so it is structurally impossible for the window edge to clip it — content scrolls, the buttons stay put (contract I34; the 2026-07-07 fix for a stale height mirror that under-sized the window). With the 2026-05-25 dedupe + exact-time-no-all-day tightening, most previews fit without scrolling anyway.Pinned by default, with a startup migration — Calendar Quick Add is high-frequency for users with Google connected, so the new tab needs to be visible without manual pinning. The one-shot migration backfills existing installs once; the sentinel
calendarQuickAddBackfilledAtprevents it from re-running every launch.Main-window toast, not an in-window pane (changed 2026-06-23) — a successful create closes the overlay immediately and confirms with a small bottom-left toast in the main Omniscio window (Open + Undo), instead of the old in-window pane that lingered for 4 seconds. Because the overlay is a separate window, the toast is relayed to the main window (
QUICK_LAUNCH_CALENDAR_NOTIFY_SUCCESS→CALENDAR_QUICK_ADD_CONFIRMED) — a renderer can't fire a toast in another window. Undo is one click (with a "Removed" confirmation); no re-prompt.Calendar picker (2026-07-01) — the tab now offers a calendar chooser and remembers your pick as the default (
calendarQuickAddDefaultCalendarId). Two deliberate constraints keep it from being clutter: it renders only when you have 2+ writable calendars (a single-calendar account never sees it), and it filters to calendars you can actually write to (via each calendar'saccessRolefrom the Google API) so a create never silently fails on a read-only feed. Primary stays the default until you choose otherwise. This supersedes the original "primary only, use the Calendar AI chat instead" stance."…of the month" without "every" offers repeating-vs-once, not whole-day-vs-morning (2026-06-02) — "Team meeting first Monday of the month" genuinely could mean a recurring monthly meeting or a single upcoming event, so Omniscio surfaces both rather than guessing. The two rows land on the real next occurrence of the pattern (computed by
firstMonthlyOccurrence), never onnowand never on a bare "next Monday" — an earlier draft mis-read the bare phrase as a one-shot on the next plain weekday with no recurrence at all. For these anchored-date phrases the all-day/9 AM alternative is dropped: the row budget is better spent on the recurring-vs-once decision, and a specific calendar anchor makes the morning-slot guess noise. Adding "every" removes the ambiguity and collapses to a single recurring row.A high-precision date scanner runs first, so a date can sit anywhere and never leaks into the title (2026-06-14) — the original parser could only spot a date at the very end of what you typed; anything after it (a time, an event name) made the date invisible, so the parser silently fell back to today and dumped the date text into the title. A dedicated scanner now finds an explicit date (numeric, ISO, month-name, bare day-of-month, weekday+day) wherever it sits and runs before the older suffix walk — so "6/21 12pm vr event" books June 21 with the clean title "Vr event" instead of "today" with "6/21 vr event" stuck in the title. It is deliberately precise over greedy: a lone number is never a date (no accidental events from "invoice 4521" or "room 1530"), and a bare dash/dot needs a year ("sprint 2-3" isn't a date). Two product calls shaped it — "next Sunday" skips to next week's Sunday (the consistent rule for every "next <weekday>"), and when a weekday and a day-of-month disagree ("Mon the 21st" when the 21st is a Sunday) Omniscio shows both dates rather than guessing. The whole change lives in the calendar layer; the shared snooze engine that powers session-snooze and alarms is untouched.
Typed time zones are honored, not dropped (2026-07-01) — the parser used to strip a zone token (EST) and book the event in your computer's zone, silently ignoring what you wrote — wrong for anyone not already in that zone. Now the zone is captured and the event is created at that wall-clock in that IANA zone and tagged with it, so "9am EST" is 09:00
America/New_Yorkregardless of the host. The conversion goes through a small DST-correct shared helper (wall-clock-zone.ts) that recomputes the instant with the target zone's real offset for that date, and it emits an offset-bearing ISO so the strict create-event schema (which requires an offset) is untouched. Coverage is abbreviation→IANA for the common US zones + UTC/GMT; anything unrecognized still falls through to the AI parser, and no-zone input is byte-identical to the old host-zone behavior. Reported via in-app feedback (a Pacific-time user whose "EST" events landed at the wrong hour).A dangling "every" is understood, and a Repeat box is the fallback (2026-07-06) — typing the day first and the repeat last ("Kathleen Tuesday 10am every") used to leave the bare word "every" stranded in the title with no recurrence, because the parser only recognized a complete phrase like "every Tuesday". Now, when a leftover "every" / "every other" has a weekday to anchor it, the parser reads it as every [other] <that weekday> — built from the day the event actually lands on — and produces a single weekly / biweekly row; with no weekday it just cleans the stray word out of the title. This only kicks in after the normal recurrence match misses, so nothing that already parsed changes (verified against the full ~1,500-phrase test set with zero regressions). For everything the text still can't express — an exact date you want to repeat, an unusual phrasing — a small Repeat checkbox + frequency picker below the preview lets you force it. The box is renderer-only (it reuses the same recurrence builder the parser uses) and defaults to mirroring any recurrence the text already implied, so it never disagrees with the preview. Reported via a screenshot of "Kathleen Tuesday 10:00 AM every" previewing as the title "Kathleen every".
For agents
Where each piece lives: the tab component, the action registry entry, the parser and its date scanner, the shared clock primitive and time-zone helper, the calendar picker, the fallback AI service, the IPC channels, the daily cost cap, the startup migration, the voice mic and the UI anchors. Behaviour, decisions and rationale are above; this section is the map of the code that produces them.
How it works (for repo-aware readers)
- Tab component: src/renderer/src/features/quick-launch/QuickLaunchCalendarTab.tsx — owns the single
itemstate plususingLlm(CALENDAR_PARSE_LLM_STARTED listener), debounced parse (300 ms), preview row selection, ResizeObserver-driven window-height request. On a successful create it relaysQUICK_LAUNCH_CALENDAR_NOTIFY_SUCCESSand closes the overlay — the confirmation toast (Open + Undo) is shown by the main window, not an in-window pane. No Zustand (Quick Launch is its own renderer process with no store sharing). A renderer-only Repeat control (repeatMode: 'auto' | 'off' | RepeatFreq+ the exportedeffectiveRepeatRrule(row, mode)) overrides the recurrence applied to the previewed/submitted row via the sharedparseRecurrencePhraseToRrule— no parser, IPC, or backend change;'auto'mirrors the parsed rrule so unchecked is byte-identical to before (contractthe-repeat-control-is-a-renderer-only-override). - Action registry: declared in src/shared/quick-launch-actions.ts with
id: 'calendar',iconName: 'Calendar',enabledSelector: (s) => s.calendarEnabled. Pinned by default —calendaris the last entry inDEFAULT_QUICK_LAUNCH_PINNED_ACTION_IDS. Component map in src/renderer/src/features/quick-launch/quick-launch-action-components.ts. - Parser (regex-first, ~99% coverage): src/shared/calendar-quick-add-parser.ts — anchored-regex layers (snooze-parser style) for date/time/duration extraction PLUS — running FIRST, ahead of the suffix walk and the day+clock scan — a high-precision explicit-date scanner (src/shared/calendar-date-scan.ts
scanDate) that locates a calendar date anywhere in the body (numericM/D/M/D/Y, ISOY-M-D, dash/dotM-D-Ywith an explicit year, month-name ± ordinal in either order, a bare day-of-month with an ordinal, and weekday+day-of-month) and resolves it to a local-midnight date; the residual keeps any clock and becomes the title. It must run with priority because the suffix walk would otherwise grab a trailing clock-only span (6/21 12pm vr event→ the clock → today) or let the snooze engine misreadsun 21as 21:00. Precision over recall: a lone integer is never a date (invoice 4521, or a whole-body15/1530/60),next <weekday>resolves to the occurrence after the nearest, and a weekday + day-of-month that disagree emit BOTH dates (via: 'weekday-daynum-conflict'). The older Phase-2findDateAnywherescan (day-token + clock-token composition — "tomorrow 10am birthday", "10am tomorrow gym", "meeting at 3 today") remains as a fallback after the suffix walk and also rescues a stranded day word from the title ("lunch tomorrow at noon" → "Lunch"). The shared snooze engine is untouched. Recurrence detection in src/shared/calendar-quick-add-rrule.ts produces full RRULE strings (RRULE:FREQ=WEEKLY;BYDAY=MO,FR;COUNT=10). A dangling "every" / "every other" the complete-phrase matcher (INLINE_RECURRENCE_RE) misses is caught in Step 2 by the end-anchoredDANGLING_RECURRENCE_RE, stripped from the title, and — when a weekday survives — synthesized in Step 5 intoevery [other] <weekday>from the RESOLVED event day (so a redundant "Ruby Tuesday … Friday" can't mis-anchor), giving a single weekly/biweekly row; it fires only after the inline match misses (contracta-dangling-every-is-a-recurrence). Returns up to 2QuickAddInterpretationrows ranked by confidence — 1 when the input carried an exact clock, 2 (all-day + 9 AM alternative) when the input was date-only, and 2 (recurring + one-shot) for an anchored-monthly "…of the month" phrase typed without "every". For those anchored-monthly phrases the event start is computed fromfirstMonthlyOccurrence(rrule, now)(the next real occurrence — first Monday of the month lands on the upcoming first-Monday, not a bare "next Monday") and the all-day/9 AM pair is suppressed in favour of the repeating-vs-once choice. The exporteddedupeInterpretations(rows)helper collapses any two rows whose render-identity (title|allDay|startIso|rrule) matches — the recurrence label is painted on a second line, so a recurring row and a one-shot row on the same date stay distinct — so an LLM-fallback path that synthesizes visually-identical rows can't surface duplicates either. Pure-shared, no Electron / IPC dependencies — runs identically in the renderer for preview and in the main process for IPC. Every clock shape — bare, compact (805p), sloppy (8.05p/8;05pm), and both ends of a range (8-9p) — resolves through the one shared clock primitiveparseClockTokenexported from src/shared/snooze-time-parser.ts (the same primitive snooze and alarms use);stripTimeRangeextracts the range and feeds the end clock tocomputeEndDate. - Time-zone honoring: the parser's
extractTimezone(in calendar-quick-add-parser.ts) captures a written zone — replacing the oldstripTimezonethat discarded it — andstampZonetags every interpretation with the resolved IANA zone. The renderer'sbuildCreateEventInputconverts the wall-clock to an offset-bearing instant for that zone via the shared, DST-correct wall-clock-zone.ts (hostIsoToZonedInstantIso+zoneAbbreviationfor the preview label) and sends{ dateTime, timeZone }, so the strictz.string().datetime({ offset: true })create schema is untouched. No zone typed → host zone (byte-identical to prior behavior). Abbreviation→IANA covers the US zones + UTC/GMT; anything unrecognized falls through to the AI fallback. Locked by wall-clock-zone.test.ts (summer + winter offsets) and the zone block in calendar-quick-add-wide-hardening.test.ts — contracta-typed-timezone-is-captured-and-honored. - Calendar picker: pure helper quick-launch-calendar-picker.ts (
writableCalendars/resolveInitialCalendarId/calendarPickerLabel,isWritableCalendarfail-open) filters the calendar list to writable calendars —CalendarInfo+listCalendarsnow carryaccessRole. The tab renders a<Select>only when ≥2 writable calendars exist, seeds it from the savedcalendarQuickAddDefaultCalendarId(falling back to primary when the saved calendar is gone), and persists a change through the canonicalpersistSetting()fire-and-forget path.'primary'maps toundefinedon submit so an unchanged default produces a byte-identical create payload. Contractthe-user-picks-the-calendar-and-the-choice-sticks. - LLM fallback (Haiku via OpenRouter): src/main/services/google/calendar-ai-service.ts
quickAddParse()— runs ONLY when the regex parser returns zero rows AND the $0.10/day cap is not exhausted. EmitsCALENDAR_PARSE_LLM_STARTEDpush immediately before the Haiku call so the renderer can flip on the "AI is thinking…" indicator. JSON-mode routed through OpenRouter (Anthropic-direct silently dropsresponse_format). Schema-validated via Zod, circuit-breaker-wrapped, every call writessource: 'calendar-quick-add'toapi_cost_logso the cap query (getTodayCostByLabel) sees it tomorrow. - IPC:
CALENDAR_PARSE_QUICK_ADD— input{ item: string }(therepeat?: stringfield was dropped 2026-05-24 along with the Repeat UI input), output{ interpretations: QuickAddInterpretation[], parseSource: 'regex' | 'llm' | 'cap-exhausted' | 'llm-failed' }. Zod-validated viawrapHandler().CALENDAR_PARSE_LLM_STARTED(push, main → renderer) — empty-payload fire-and-forget signal that a Haiku call is in flight. Drives the AI-thinking indicator. Registered inCRITICAL_PUSH_CHANNELS+CRITICAL_SCHEMA_MAPfor the strict-payload check and exempted from the mobile-push-listener-coverage lint as a desktop-only overlay (Quick Launch isn't a mobile surface).QUICK_LAUNCH_SET_WINDOW_HEIGHT— input{ height: number }, clamped to[420, 720]in main and re-centered around the primary display. The renderer uses aResizeObserveron the preview list'sscrollHeightto grow the window when the interpretation rows would clip; it snaps back to 420 on success / cancel / unmount and in the empty state (the observer only runs once there are interpretations, so the idle window stays at its compact default). The preview list stays content-sized (never flex-grow) so its measured height can't feed back into the window it drives — invariant I35 (fixes the 2026-07-07 giant-empty-box regression). Best-effort — failures fall back to internal scroll within the default-size window.CALENDAR_CREATE_EVENT— existing handler used unchanged. Calendar Quick Add adds no new backend write paths; it front-ends the samecreateEvent()that the Calendar AI chat already uses.CALENDAR_DELETE_EVENT— used by the toast's Undo link (now in the main window). Existing handler.CALENDAR_LIST_CALENDARS— existing handler; its output now includesaccessRoleper calendar so the picker can offer only writable ones.SETTINGS_GET/SETTINGS_UPDATE— the picker reads the saved default calendar on mount and persists a change (viapersistSetting()); no new channels, reused from the shared settings path.QUICK_LAUNCH_CALENDAR_NOTIFY_SUCCESS— invoke (QL → main): relays a successful create so main can re-emit the toast push.CALENDAR_QUICK_ADD_CONFIRMED— push (main → main window): the main-window listener fires the confirmation toast. Both carry{ summary, htmlLink?, eventId, calendarId? }(one shape).
- Daily $ cap: enforced by src/main/services/api-cost-tracker.ts
getTodayCostByLabel('calendar-quick-add'). ConstantQUICK_ADD_DAILY_USD_CAP = 0.10at the top ofcalendar-ai-service.ts. Pre-flight gate fires before the OpenRouter call; mid-flight cost-rollup keeps the cap honest across concurrent QL opens. - Startup migration: one-shot backfill in src/main/index.ts after the persisted log level apply — appends
'calendar'toAppSettings.quickLaunchPinnedActionIdsfor existing installs, then sets the sentinelAppSettings.calendarQuickAddBackfilledAtto today's ISO timestamp so the backfill never re-runs. Idempotent and non-fatal — wrapped in a try/catch that logs and continues. - Voice mic: same QuickLaunchVoiceMic used by other tabs — partial transcripts append to the Event field. Inline recurrence in the transcript flows through the regex parser like any other input.
- UI anchors: 8 entries, co-located in quick-launch-calendar-tab.ui-anchors.ts (assembled via
npm run ui-anchors:reindex) — tab button, Event textarea, AI badge, preview list, first preview row, Create button, the Reconnect-Google button (quick-launch-calendar-reconnect-google) shown on an auth failure, and the Repeat checkbox (quick-launch-calendar-repeat). The former success-pane / Undo / Open-in-Google anchors were removed on 2026-06-23 when the success pane became a main-window toast. Required for App Tour spotlights and theGET /ui/snapshotCLI route.
Related
The user-facing walkthrough of the tab is on the parent page. The composer it lives in is the Quick Launch modal, the connection that gates it is on the Google Integrations page, and the alarm tab that shares the same natural-language, regex-first, Haiku-fallback shape is on the Alarms page. The date and time grammar is the same engine as the one on the Snooze a session page.
Last verified 2026-10-06