|
| 1 | +# Plan — 2026-07-09 |
| 2 | + |
| 3 | +Five spec items, one PR each off `main`, shipped via the `ship` skill (auto-merge on green CI). |
| 4 | +Ordering below is the intended ship order: cheap/mechanical first, the hard nudge item mid-day |
| 5 | +with a fresh Opus/high context, forms last. |
| 6 | + |
| 7 | +Answers locked at the morning gate: |
| 8 | + |
| 9 | +- **Item 1:** Left-align everything (header, message, buttons); buttons ordered **Log out → Cancel**, left-aligned. |
| 10 | +- **Item 2:** **Broad** copy rewrite across the full audited list — but errors stay *functional* (colloquial ≠ vague; keep the "what to do next"). |
| 11 | +- **Item 5:** **Shared form shell only** — extract the inner `<form>` wrapper; leave each form's floating/positioning wrapper as-is. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Item 1 — fix(web): logout dialog + command-palette keycap |
| 16 | + |
| 17 | +**Files:** |
| 18 | + |
| 19 | +- `packages/web/src/components/OverlayPanel/OverlayPanel.tsx` — `OverlayPanelActions` currently `flex w-full justify-end`. |
| 20 | +- `packages/web/src/components/LogoutConfirmation/LogoutConfirmationDialog.tsx` — button order. |
| 21 | +- `packages/web/src/common/hooks/useLogoutCmdItems.ts` — `shortcut: "z"` (line 19). |
| 22 | +- `packages/web/src/components/CommandPalette/CommandPalette.tsx` (renders `item.shortcut`). |
| 23 | + |
| 24 | +**Approach:** |
| 25 | + |
| 26 | +- The panel root is `flex flex-col items-center` (OverlayPanel.tsx:65). `items-center` is why the |
| 27 | + message + actions don't share the header's left edge. The panel is **shared** with the auth |
| 28 | + status loader (`variant="status"`, centered spinner) — so I will **not** flip `items-center` |
| 29 | + globally. Instead: |
| 30 | + - Give the `message` `<p>` `w-full` so its text aligns to the same left edge as the full-width title. (Title already has `w-full`.) |
| 31 | + - Change `OverlayPanelActions` from `justify-end` to `justify-start` **only for the modal use** — cleanest is to keep the panel's `items-center` but make title/message/actions all `w-full`, and left-justify the actions. Verify the status loader still looks right (it has no actions row, so unaffected). |
| 32 | + - Actually simplest & safest: switch panel root to `items-start` for `variant === "modal"` only (status keeps `items-center`). Then title/message/actions naturally left-align; add `w-full` where needed for the buttons row. I'll pick whichever reads cleaner once I see it in the preview; both are contained to the modal variant. |
| 33 | +- Reorder buttons in `LogoutConfirmationDialog` to **Log out (primary) → Cancel**. No extra tests for ordering (per spec). |
| 34 | +- Remove `shortcut: "z"` from the Log Out command item so no keycap renders (the palette already conditionally renders `item.shortcut`, so dropping the field is sufficient). Confirm no code still binds `z` to logout. |
| 35 | + |
| 36 | +**Deliberately not doing:** no new tests solely for button order; not touching the status/loader variant's alignment. |
| 37 | + |
| 38 | +**Manual test:** open logout dialog (via command palette / account menu) → header, message, buttons share left edge; order is Log out then Cancel; Esc + backdrop still dismiss; focus trap intact. Command palette → Log Out row shows **no** `Z` keycap. |
| 39 | + |
| 40 | +--- |
| 41 | + |
| 42 | +## Item 2 — fix(web): more human copy (broad) |
| 43 | + |
| 44 | +**Primary:** `packages/web/src/views/GoogleAuthCallback/GoogleAuthCallback.tsx:65` |
| 45 | +`"Completing Google authorization..."` → `"Just finishing up …"`. |
| 46 | + |
| 47 | +**Broad sweep (audited candidates, colloquial + still functional):** |
| 48 | + |
| 49 | +- `auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts` — "Checking Google Calendar…", "Syncing Google Calendar…", "Syncing...", "Google Calendar needs a sync", "Google Calendar needs reconnecting". |
| 50 | +- `components/PlannerSidebar/PlannerAccountSummary/PlannerAccountSummary.tsx:87` — "Syncing changes…". |
| 51 | +- `auth/google/hooks/useConnectGoogle/useConnectGoogle.ts:78` — sync-failed toast. |
| 52 | +- `auth/google/authorization/google-authorization.constants.ts:15,17` — OAuth failure / missing-permissions. |
| 53 | +- `auth/google/util/google.auth.util.factory.ts:16,18` — local-sync failure / session-expiry-during-save. |
| 54 | +- `components/LoginAbsoluteOverflowLoader/LoginAbsoluteOverflowLoader.tsx:52,57` — login loader messages. |
| 55 | +- `common/utils/storage/db-errors.util.ts:61,69,77` — quota / version-mismatch / unexpected-close. |
| 56 | +- `common/utils/app-init.util.ts:43` — offline-storage-unavailable. |
| 57 | +- `components/AuthModal/hooks/useAuthFormHandlers.util.ts:9` — backend-unreachable. |
| 58 | +- `auth/compass/user/hooks/useLoadProfile.ts` (`showSessionExpiredToast`) — session expired. |
| 59 | +- `common/apis/util/api.util.ts:81` — "Login required, cuz security 😇" (already casual; keep or lightly tidy). |
| 60 | +- Leave already-friendly ones (`uff-dah…`, "Check your email") as-is. |
| 61 | + |
| 62 | +**Guardrails:** replace jargon (authorization, quota, backend, database version) with plain words, |
| 63 | +keep the actionable instruction in every error. If a string is asserted in a test, update the test. |
| 64 | + |
| 65 | +**Manual test:** trigger the Google auth redirect → spinner reads "Just finishing up …". Spot-check a couple of the reworded toasts (sync failure, session expiry) still make sense. |
| 66 | + |
| 67 | +--- |
| 68 | + |
| 69 | +## Item 3 — fix(web): nudge lag + replay (the hard one; Opus/high) |
| 70 | + |
| 71 | +**Root cause (confirmed by trace):** every keydown (incl. OS key-repeat) calls `mutate()`, which does |
| 72 | +an instant optimistic cache write **plus** a network PUT. PUTs are serialized per-event by |
| 73 | +`waitForPrecedingEventWrites` (`events/mutations/event.mutation.runtime.ts`) to avoid Mongo |
| 74 | +write-conflict 500s, with **no coalescing** — so a burst enqueues N PUTs that drain one-at-a-time |
| 75 | +after the user stops (the "replay"), and the queue can error under load. |
| 76 | + |
| 77 | +**Files:** |
| 78 | + |
| 79 | +- `packages/web/src/common/utils/event/event-nudge-shortcut.util.ts` (`nudgeEventFromKeyboard`). |
| 80 | +- `packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts` (`moveFocusedCalendarEvent`, Shift+Arrow bindings). |
| 81 | +- `packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts`. |
| 82 | +- `packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventContainer/SomedayEventContainer.tsx` (`scheduleEvent`, someday→grid). |
| 83 | +- `packages/web/src/common/hooks/useUpdateEvent.ts` — already separates instant `draftActions.setEvent` from network `edit`; `saveImmediate=false` updates local state only. |
| 84 | + |
| 85 | +**Approach (keep it simple):** |
| 86 | + |
| 87 | +- Keep the visual **instant** on every keypress (optimistic/draft update — the mechanism already exists via `useUpdateEvent(payload, false)` / draft store). |
| 88 | +- **Trailing-debounce the network write** (~250 ms after the last nudge) so a burst collapses to a |
| 89 | + single PUT of the final position. Add one tiny debounce helper (none exists in the repo) — a |
| 90 | + minimal `useDebouncedCallback`/timeout, not a library. |
| 91 | +- Flush the pending write on blur / unmount / when a different event is nudged, so nothing is lost |
| 92 | + if the user navigates away quickly. |
| 93 | +- Do **not** touch the `waitForPrecedingEventWrites` serialization machinery — the debounce removes |
| 94 | + the burst before it reaches that queue, which is the simplest fix and leaves the write-conflict |
| 95 | + protection intact. |
| 96 | +- Scope: keyboard nudging (Shift+Arrow) + the someday→grid schedule path. Drag already routes |
| 97 | + through the draft layer and isn't part of the reported bug — leave it. |
| 98 | + |
| 99 | +**Spike rule:** if the instant-visual-vs-debounced-write split gets ugly after ~2 attempts (e.g. the |
| 100 | +optimistic layer fights the debounce), stop, checkpoint in summary, and reassess coalescing at the |
| 101 | +queue instead. |
| 102 | + |
| 103 | +**Manual test:** hold Shift+Right on a grid event → moves smoothly, network tab shows ~1 PUT after |
| 104 | +release (not one per step), no errors. Someday→grid via Shift+Right then nudge around → preview |
| 105 | +immediate, single settle, no trailing replay. |
| 106 | + |
| 107 | +--- |
| 108 | + |
| 109 | +## Item 4 — style(week): now line only on the current day's column |
| 110 | + |
| 111 | +**File:** `packages/web/src/common/calendar-grid/components/CalendarTimedGrid.tsx` (`CalendarNowLine`, lines 154–179; render gate at line 79). |
| 112 | + |
| 113 | +**Approach:** |
| 114 | + |
| 115 | +- Off-week guard already exists (`isTodayVisible ? <CalendarNowLine/> : null`, line 79) — no change there. |
| 116 | +- Currently the line is `absolute h-px w-full` → spans all columns. Scope it to the current day's |
| 117 | + column: compute `todayIndex = visibleDates.findIndex(date isSame today)` and pass it (+ column |
| 118 | + count) to `CalendarNowLine`; position via `left: calc(index * 100% / count)` and |
| 119 | + `width: calc(100% / count)` (matches the `repeat(count, 1fr)` grid). Keep the blue gradient + minute tick. |
| 120 | + |
| 121 | +**Manual test:** current week → now line appears only under today's column. Navigate to a non-current |
| 122 | +week → no now line. Resize / different visible-day counts → line stays aligned to today's column. |
| 123 | + |
| 124 | +--- |
| 125 | + |
| 126 | +## Item 5 — style(forms): shared form shell |
| 127 | + |
| 128 | +**Files:** |
| 129 | + |
| 130 | +- `packages/web/src/views/Forms/EventForm/EventForm.tsx` (grid `<form>`: `z-1 rounded-sm bg-(--event-form-bg) px-5 py-4.5 shadow-… transition-all duration-300`). |
| 131 | +- `packages/web/src/views/Forms/SomedayEventForm/SomedayEventForm.tsx` (someday `<form>`: same + `text-xl`). |
| 132 | +- New: `packages/web/src/views/Forms/EventFormShell.tsx` (or `components/`). |
| 133 | + |
| 134 | +**Approach:** |
| 135 | + |
| 136 | +- Extract the inner `<form>` wrapper into a content-agnostic `EventFormShell` that owns the shared |
| 137 | + container styling (padding, bg via `--event-form-bg`, shadow, rounding, transition) and takes |
| 138 | + `children`, `priority`, `onSubmit`, ref/props. Both forms render their existing sections inside it. |
| 139 | +- Reconcile the one real drift (`text-xl` on someday, title sizing) — decide a single consistent |
| 140 | + scale or expose a minimal prop; keep both forms visually intentional. |
| 141 | +- Leave floating wrappers (`FloatingEventForm`, `FloatingFormContainer`) untouched — they anchor to |
| 142 | + genuinely different things (grid event vs sidebar). Consolidating them was explicitly out of scope. |
| 143 | + |
| 144 | +**Manual test:** open a grid event form and a someday event form → identical padding/margins/shadow/ |
| 145 | +radius; fields and save/cancel behave as before; no visual regression in either. |
| 146 | + |
| 147 | +--- |
| 148 | + |
| 149 | +## Cross-cutting |
| 150 | + |
| 151 | +- One branch + PR per item, off `main`; `ship` skill validates → reviews → opens PR → watches CI → |
| 152 | + squash-merges on green. Merge gated on `gh pr checks` exit + `mergeStateStatus == CLEAN`. |
| 153 | +- Respect `.claude/settings.json` deny-list; any denied need → push-notify, not a workaround. |
| 154 | +- Live browser QA (`preview_*`) while screen is unlocked; otherwise CI is the gate and QA is |
| 155 | + sequenced for staging review. |
| 156 | +- Evening: run `simplify` over the day's diffs, land cleanup as its own PR(s). |
| 157 | +- Keep `summary.md` append-only through the day. |
| 158 | + |
| 159 | +--- |
| 160 | + |
| 161 | +# Item 6 (new handoff) — feat: allow events to have empty title (#1871) |
| 162 | + |
| 163 | +PO answers locked at the gate: |
| 164 | +- **Existing "untitled" events:** *leave them, fix going forward.* No inbound string-matching of |
| 165 | + "untitled"/"(No title)". Just stop creating the default; existing ones clear when edited. |
| 166 | +- **Empty display:** *fully blank block.* Keep the visible card textless; keep the "Untitled event" |
| 167 | + string only in the screen-reader aria-label (accessibility). |
| 168 | + |
| 169 | +## Root cause (verified in code) |
| 170 | + |
| 171 | +`packages/core/src/mappers/map.event.ts:168-169` — `gEventDefaults.summary = "untitled"`. The |
| 172 | +gcal→compass mapper does `mergeWith({}, gEventDefaults, gEvent)` then `title = event.summary!`. |
| 173 | +lodash merge skips only `undefined` sources, so when Google **omits** `summary` (which is what the |
| 174 | +API does for untitled events — "(No title)" is display-only, never sent), the `"untitled"` default |
| 175 | +lands in `title`. That single default is the bug. |
| 176 | + |
| 177 | +## Scope — two one-line source changes, no frontend changes |
| 178 | + |
| 179 | +1. **`packages/core/src/mappers/map.event.ts:169`** — `summary: "untitled"` → `summary: ""`. |
| 180 | + Absent Google summary now maps to `""`. Empty/real summaries already pass through unchanged. |
| 181 | + |
| 182 | +2. **`packages/core/src/types/event_new.types.ts:49`** — `title: StringV4Schema` → `title: z.string()`. |
| 183 | + `StringV4Schema` is `z.string().nonempty()` and is **shared** (gEventId, rrule, etc.), so give |
| 184 | + `title` its own `z.string()` rather than relaxing the shared symbol. This v4 `EventSchema` is dead |
| 185 | + at runtime today — it only feeds a Mongo `$jsonSchema` validator via a migration |
| 186 | + (`packages/scripts/src/migrations/…new-events-collection.ts`) — but relaxing it now prevents a |
| 187 | + latent DB-level rejection of `""` if/when that collection goes live. Keeps intent consistent. |
| 188 | + |
| 189 | +## Deliberately NOT changing (verified already correct) |
| 190 | + |
| 191 | +- **Outbound compass→gcal** (`map.event.ts:105`, `if (event.title) gcalEvent.summary = …`): omitting |
| 192 | + `summary` is correct. `updateEvent` uses `gcal.events.update` (PUT / full-resource replace, |
| 193 | + `gcal.service.ts:218`), so an omitted summary **clears** the title in Google — the desired |
| 194 | + round-trip. No change. |
| 195 | +- **Frontend**: display already renders raw `event.title` (blank when empty); the "Untitled event" |
| 196 | + fallback is aria-label-only (`CalendarTimedEventCard.tsx:167`, `CalendarAllDayEventCard.tsx:87`) — |
| 197 | + matches PO's "fully blank block, keep a11y". Forms already accept empty titles; web schemas extend |
| 198 | + `CompassCoreEventSchema` (`title: z.string().optional()`) with no override. No web changes. |
| 199 | +- **Log-only fallbacks** (`gcal.event.parser.ts:121` `?? "unknown"`, `compass.event.parser.ts:545`) |
| 200 | + — not persisted, don't affect titles. Leave. |
| 201 | +- No inbound "untitled"/"(No title)" normalization (PO chose leave-existing). |
| 202 | + |
| 203 | +## Tests |
| 204 | + |
| 205 | +- Add cases to `packages/core/src/mappers/map.event.to-compass.test.ts` (or the to-compass test): |
| 206 | + gcal event with **absent** summary → compass `title === ""`; gcal event with real summary → unchanged. |
| 207 | +- Sweep google-to-compass propagation test utils/fixtures for reliance on the `"untitled"` default |
| 208 | + (`google-to-compass.event-propagation.test.util.ts`, upsert tests) and the driver default |
| 209 | + `event.driver.ts:64` (`|| "No Title"`); update only what the new behavior actually breaks. |
| 210 | +- Run `bun run type-check` (v4 schema edit) + web/core/backend unit suites. |
| 211 | + |
| 212 | +## Ship |
| 213 | + |
| 214 | +One PR off `main` via the `ship` skill (branch e.g. `feat/empty-event-title`), title |
| 215 | +`feat(core): allow events to have empty title`. Auto-merge on green CI. Manual Testing Steps in the |
| 216 | +PR: create an event with no title → saves + displays as a blank block; (staging, authed) it syncs to |
| 217 | +Google as "(No title)" and syncing back keeps it blank. |
| 218 | + |
| 219 | +## Manual test (local + staging) |
| 220 | + |
| 221 | +- Local preview: create/save an event leaving the title empty → no error, card renders blank, reopens |
| 222 | + with an empty title field (placeholder "Title" shows). |
| 223 | +- Staging (authed, sequenced): empty-title event → appears in Google Calendar as "(No title)"; edit |
| 224 | + its time in Google → title stays blank in Compass on next sync. |
0 commit comments