---
title: Calendar Quick Add (part 2)
---

# Calendar Quick Add (part 2)

## What it is

This is part 2 of the [Calendar Quick Add](calendar-quick-add.md) 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](calendar-quick-add.md). 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

1. **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).

2. **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.

3. **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.

4. **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-0` sibling), 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.

5. **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 `calendarQuickAddBackfilledAt` prevents it from re-running every launch.

6. **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.

7. **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's `accessRole` from 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.

8. **"…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 on `now` and 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.

9. **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.

10. **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_York` regardless of the host. The conversion goes through a small **DST-correct** shared helper ([wall-clock-zone.ts](../../src/shared/datetime/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).

11. **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] &lt;that weekday&gt;_ — 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](../../src/renderer/src/features/quick-launch/QuickLaunchCalendarTab.tsx) — owns the single `item` state plus `usingLlm` (CALENDAR_PARSE_LLM_STARTED listener), debounced parse (300 ms), preview row selection, ResizeObserver-driven window-height request. On a successful create it relays `QUICK_LAUNCH_CALENDAR_NOTIFY_SUCCESS` and 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 exported `effectiveRepeatRrule(row, mode)`) overrides the recurrence applied to the previewed/submitted row via the shared `parseRecurrencePhraseToRrule` — no parser, IPC, or backend change; `'auto'` mirrors the parsed rrule so unchecked is byte-identical to before (contract `the-repeat-control-is-a-renderer-only-override`).
- **Action registry**: declared in [src/shared/quick-launch-actions.ts](../../src/shared/quick-launch-actions.ts) with `id: 'calendar'`, `iconName: 'Calendar'`, `enabledSelector: (s) => s.calendarEnabled`. Pinned by default — `calendar` is the last entry in `DEFAULT_QUICK_LAUNCH_PINNED_ACTION_IDS`. Component map in [src/renderer/src/features/quick-launch/quick-launch-action-components.ts](../../src/renderer/src/features/quick-launch/quick-launch-action-components.ts).
- **Parser (regex-first, ~99% coverage)**: [src/shared/calendar-quick-add-parser.ts](../../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](../../src/shared/calendar-date-scan.ts) `scanDate`) that locates a calendar date anywhere in the body (numeric `M/D` / `M/D/Y`, ISO `Y-M-D`, dash/dot `M-D-Y` with 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 misread `sun 21` as 21:00. **Precision over recall**: a lone integer is never a date (`invoice 4521`, or a whole-body `15` / `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-2 `findDateAnywhere` scan (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](../../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-anchored `DANGLING_RECURRENCE_RE`, stripped from the title, and — when a weekday survives — synthesized in Step 5 into `every [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 (contract `a-dangling-every-is-a-recurrence`). Returns up to **2** `QuickAddInterpretation` rows 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 from `firstMonthlyOccurrence(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 exported `dedupeInterpretations(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 primitive `parseClockToken` exported from [src/shared/snooze-time-parser.ts](../../src/shared/snooze-time-parser.ts) (the same primitive snooze and alarms use); `stripTimeRange` extracts the range and feeds the end clock to `computeEndDate`.
- **Time-zone honoring**: the parser's `extractTimezone` (in [calendar-quick-add-parser.ts](../../src/shared/calendar-quick-add-parser.ts)) **captures** a written zone — replacing the old `stripTimezone` that discarded it — and `stampZone` tags every interpretation with the resolved IANA zone. The renderer's `buildCreateEventInput` converts the wall-clock to an **offset-bearing** instant for that zone via the shared, DST-correct [wall-clock-zone.ts](../../src/shared/datetime/wall-clock-zone.ts) (`hostIsoToZonedInstantIso` + `zoneAbbreviation` for the preview label) and sends `{ dateTime, timeZone }`, so the strict `z.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](../../tests/unit/shared/datetime/wall-clock-zone.test.ts) (summer + winter offsets) and the zone block in [calendar-quick-add-wide-hardening.test.ts](../../tests/unit/shared/calendar-quick-add-wide-hardening.test.ts) — contract `a-typed-timezone-is-captured-and-honored`.
- **Calendar picker**: pure helper [quick-launch-calendar-picker.ts](../../src/renderer/src/features/quick-launch/quick-launch-calendar-picker.ts) (`writableCalendars` / `resolveInitialCalendarId` / `calendarPickerLabel`, `isWritableCalendar` fail-open) filters the calendar list to writable calendars — `CalendarInfo` + `listCalendars` now carry `accessRole`. The tab renders a `<Select>` only when ≥2 writable calendars exist, seeds it from the saved `calendarQuickAddDefaultCalendarId` (falling back to primary when the saved calendar is gone), and persists a change through the canonical `persistSetting()` fire-and-forget path. `'primary'` maps to `undefined` on submit so an unchanged default produces a byte-identical create payload. Contract `the-user-picks-the-calendar-and-the-choice-sticks`.
- **LLM fallback (Haiku via OpenRouter)**: [src/main/services/google/calendar-ai-service.ts](../../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. **Emits `CALENDAR_PARSE_LLM_STARTED` push immediately before the Haiku call** so the renderer can flip on the "AI is thinking…" indicator. JSON-mode routed through OpenRouter (Anthropic-direct silently drops `response_format`). Schema-validated via Zod, circuit-breaker-wrapped, every call writes `source: 'calendar-quick-add'` to `api_cost_log` so the cap query (`getTodayCostByLabel`) sees it tomorrow.
- **IPC**:
  - `CALENDAR_PARSE_QUICK_ADD` — input `{ item: string }` (the `repeat?: string` field was dropped 2026-05-24 along with the Repeat UI input), output `{ interpretations: QuickAddInterpretation[], parseSource: 'regex' | 'llm' | 'cap-exhausted' | 'llm-failed' }`. Zod-validated via `wrapHandler()`.
  - `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 in `CRITICAL_PUSH_CHANNELS` + `CRITICAL_SCHEMA_MAP` for 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 a `ResizeObserver` on the preview list's `scrollHeight` to 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 same `createEvent()` 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 includes `accessRole` per 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 (via `persistSetting()`); 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](../../src/main/services/api-cost-tracker.ts) `getTodayCostByLabel('calendar-quick-add')`. Constant `QUICK_ADD_DAILY_USD_CAP = 0.10` at the top of `calendar-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](../../src/main/index.ts) after the persisted log level apply — appends `'calendar'` to `AppSettings.quickLaunchPinnedActionIds` for existing installs, then sets the sentinel `AppSettings.calendarQuickAddBackfilledAt` to 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](../../src/renderer/src/features/quick-launch/QuickLaunchVoiceMic.tsx) 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](../../src/renderer/src/features/quick-launch/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 the `GET /ui/snapshot` CLI route.

## Related

The user-facing walkthrough of the tab is on the [parent page](calendar-quick-add.md). The composer it lives in is the [Quick Launch modal](quick-launch-modal.md), the connection that gates it is on the [Google Integrations](google-integrations.md) page, and the alarm tab that shares the same natural-language, regex-first, Haiku-fallback shape is on the [Alarms](alarms.md) page. The date and time grammar is the same engine as the one on the [Snooze a session](snooze-a-session.md) page.
