Replace the picker with an accessible combobox (role=combobox + role=listbox):
focusing shows the full zone list (no typing/erasing needed), typing filters it
case-insensitively (underscores ignored, so "york" matches America/New_York),
with arrow-key navigation, Enter/click to select, and Escape to close. Fixes the
datalist limitation where a pre-filled value collapsed the dropdown to one match.
e2e updated to type+click options and a type-to-search case added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IANA picker was an <input list=datalist>, which filters the dropdown by
whatever text is already in the field — so with the stored zone pre-filled a
user only saw a single option and had to erase the value (undiscoverable) to
browse. datalist is also unreliable in iOS Safari.
Replace it with a native <select> grouped by region (<optgroup>): tapping
shows the whole list with no typing/erasing, and it renders as the native
wheel picker on iOS. The "Use detected" one-tap shortcut still covers the
common case. Option labels are shortened (region stripped, underscores → spaces)
while values remain full IANA ids. e2e updated from fill() to selectOption().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Derive isExplicit from timezoneQuery.data?.isExplicitlySet
- Apply the input===stored no-op guard only when isExplicit is true
- Keep pending and empty-input guards unconditional
- Add unit tests (AdminPage.timezone.test.ts) verifying first-run Save is
enabled when isExplicitlySet:false and input matches stored fallback value
- Import fetchAdminTimezone + setAdminTimezone from api/client.js
- timezoneQuery: useQuery(['admin','timezone'], fetchAdminTimezone, retry:false, staleTime:60s)
- timezoneMutation: useMutation(setAdminTimezone) with invalidateQueries on success
- Timezone <section aria-label="Timezone"> after Shared Calendar (with marginBottom on preceding section)
- Searchable <input type=text list=iana-zones> + <datalist> from Intl.supportedValuesOf (guarded)
- 'Use detected: <zone>' affordance for D-02 one-tap seed
- 'Using system default' note when isExplicitlySet === false (D-06)
- Save button disabled while pending or when input equals stored value
- No touch to eventDateTime.ts / hydrateEvents.ts / other sections (D-07)
- Export AdminTimezoneResponse interface (timezone: string, isExplicitlySet: boolean)
- fetchAdminTimezone(): GET /api/admin/config/timezone with credentials/redirect pattern
- setAdminTimezone(timezone): PUT /api/admin/config/timezone with JSON body
- Both wrappers call handleAuthResponse (same auth handling as sibling admin calls)
fast-checks failed on 3 no-unnecessary-type-assertion ESLint errors (reminderIsCustom is now a real CalendarOccurrence field). api failed on 4 all-day 9 AM-local tests that assumed a UTC-4 host; CI runs UTC. Pin process.env.TZ=America/New_York in the all-day describe (production code reads TZ at call time, D-04).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- helper text condition now uses (allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS)
- previously checked !TIMED && !ALLDAY: a timed event with 10080 (in ALLDAY set) was
incorrectly treated as 'in presets' and suppressed the helper text
- synthetic option gating for each allDay/timed branch was already correct
- timed event with reminderLeadMinutes=10080 must show 'Custom reminder kept' helper
- currently suppressed: helper text checks !TIMED && !ALLDAY, but 10080 is in ALLDAY
- fix: gate helper text on active preset set only (allDay ? ALLDAY : TIMED)
- expand.ts: add reminderIsCustom:boolean to CalendarOccurrence interface;
derived from classifyValarms kind==='custom'; propagated to both
non-recurring and recurring occurrence branches
- client.ts: mirror reminderIsCustom on CalendarOccurrence (atomic mirror)
- EventForm.tsx: extend deriveReminderValue to accept isCustom flag;
returns '__custom__' when true, making the existing D-08 preserve branch
live — editing a custom-alarm event now omits reminderLeadMinutes from
the payload so outboxWorker extractValarms keeps the original VALARM
- Fix existing test fixtures (EventForm.test.tsx, EventDetailPopover.test.tsx)
to include reminderIsCustom:false on all CalendarOccurrence literals
Fixes CAL-14 Pitfall 1: Apple Calendar absolute DATE-TIME / multi-VALARM
alarms no longer silently stripped on any edit round-trip from the PWA.
- expand.test.ts: 3 new tests asserting reminderIsCustom:true for
absolute DATE-TIME trigger and multi-VALARM, false for relative preset
- EventForm.test.tsx: 3 new tests asserting __custom__ picker init,
'Custom (kept)' option visibility, and payload omits reminderLeadMinutes
- Fixtures: absolute-alarm.ics (DATE-TIME VALARM), multi-alarm.ics (2 VALARMs)
- All 6 new tests FAIL (RED): reminderIsCustom field not yet on interface
- CalendarOccurrence: required reminderLeadMinutes: number | null (atomic mirror of expand.ts, Plan 11-03)
- CreateEventPayload: optional reminderLeadMinutes?: number | null with absent/null/0/positive contract (D-08)
- Update CalendarOccurrence fixtures in EventForm.test.tsx + EventDetailPopover.test.tsx to include the new required field (reminderLeadMinutes: null)
- pwa tsc --noEmit exits 0
- Add .then(navigated) check: opens new window when client.navigate() resolves null
- Add .catch(): opens new window when client.focus() or client.navigate() rejects
- Both branches guarded by self.clients.openWindow per spec
- Returned chain (not floating) satisfies no-floating-promises gate
- All other behaviour preserved: close(), url extraction, post-loop fallback
- Replace inline 'as string | null' assertion with an explicit typed const
declaration inside the vi.hoisted() callback body
- 'value: string | null' typed const satisfies both ESLint (no assertion) and
tsc (null assignment on line 82 is type-safe)
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
type annotation on mockLastSyncedUid; remove async from test callbacks without
await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
comments land on the exact violation lines; void self.skipWaiting()
The per-family remap (shared, member-1..4) only fills all-day pills whose
Schedule-X colorName is registered. Member calendars absent from the current
/api/me members list fall back to Schedule-X's built-in primary family, which
was not remapped — so those all-day events degraded to the light tint. Remap
--sx-color-primary-container as well so all-day pills stay solid in the
fallback case too (production member-N calendars already covered).
- Add isPhone() helper using window.matchMedia('(max-width: 767px)') consistent with AppNav
- Return null when isPhone() is false (desktop ≥768px) — BottomTabBar is phone-only
- Prevents the position:fixed bottom bar from overlaying AppNav sidebar avatar/Settings on desktop
- RED test committed in prior commit (740e342)
- Lift AppNav from CalendarShell to App.tsx as a sibling of <Routes>
- App.tsx fetches /api/me (same query key as CalendarShell — deduplicated by TanStack Query)
- App.tsx provides the outer layout (phone: column, desktop: row) with AppNav always rendered
- CalendarShell simplified: no longer manages AppNav, outer flex layout stays in App.tsx
- AuthSplash gains overlay prop (position:fixed inset:0 z-index:999) so it covers AppNav when needed
- CalendarShell uses AuthSplash with overlay=true so auth splashes cover full viewport
- Remove onOpenSettings prop from CalendarShell (wired directly in App.tsx to SettingsSheet)
- Desktop sidebar nav (FamilySync brand, Calendar/Lists links) now persists on /lists route
- CalendarShell now captures maybeRedirectToLogin() return value in meQuery.isError effect
- When the one-shot guard is exhausted (returns false), arm loginRedirectExhausted state
- Render AuthSplash state=dead-end (tap-to-retry) when guard is exhausted, not indefinite redirecting spinner
- Reset loginRedirectExhausted on successful auth (meQuery.isSuccess) for session recovery
- Add sessionStorage.clear() to beforeEach so CalendarShell tests are isolated
- RED test committed in prior commit (36ef7a0)
- Replace non-existent .sx__all-day-event selector with confirmed class names
- .sx__date-grid-event: exclusively all-day in week/day view (timed go to .sx__time-grid-event)
- .sx__month-grid-event:not(:has(.sx__month-grid-event-time)): all-day in month view
- Use CSS custom property inheritance to remap -container vars to solid main colors in date-grid and month-grid-day__events scopes
- Cover shared + member-1..4 calendar colorNames (N-member design)
- Source-confirmed from @schedule-x/calendar@4.6.0 dist/core.js
- Add icon, badge, renotify:true, vibrate to showNotification options so
reused-tag updates produce heads-up + sound/vibration on Android Chromium
- Narrow cast (as NotificationOptions) to handle renotify/vibrate absent
from this lib.dom version without suppressing other errors
- Generalize ANDROID_STEPS first step to browser-agnostic (Chrome or Edge)
- Move isIOS, IOS_STEPS, ANDROID_STEPS, InstructionSheetProps, InstructionSheet from PermissionDeniedBanner verbatim
- Export InstructionSheet as named export from InstructionSheet.tsx
- PermissionDeniedBanner now imports from ./InstructionSheet.js (behaviour identical)
Plan 06-05 added hasRrule as a required field on the PWA CalendarOccurrence
type (mirroring 06-03's server-side field), but pre-existing fixtures in
EventDetailPopover.test.tsx did not set it — breaking tsc --noEmit / vite build
(vitest passed because esbuild strips types). Both fixtures are non-recurring
single events, so hasRrule: false is correct. Post-merge integration fix.
- Import computeNewTimedEnd/computeNewAllDayEnd from eventDateTime.ts (D-04)
- Start date onChange: calls computeNewAllDayEnd (all-day) or computeNewTimedEnd (timed)
- Start time onChange: calls computeNewTimedEnd preserving duration (timed only)
- Add recurrenceBound/recurrenceUntil/recurrenceCount state (D-06)
- Reset useEffect extended to reset bound state on form open
- Add 'Ends' control (Never/On date/After N times) shown when recurrence != none
- Inline validation: count < 1 and until < start
- Payload conditionally includes recurrenceUntil/recurrenceCount (create mode only)
- Error state type extended for recurrenceBound validation
- Add hasRrule to EDIT_OCCURRENCE, RECURRING_OCCURRENCE, LATE_OCCURRENCE fixtures
- Add ALL_DAY_OCCURRENCE fixture for D-05 round-trip test
- Add test cases: D-04 timed/all-day end-tracking on start change
- Add test cases: D-05 all-day edit pre-fills inclusive end (no drift)
- Add test cases: D-06 Ends control visibility, On date/After N times reveals
- Add test cases: D-06 validation (count < 1), payload emission (count, never)
- Add SessionExpiredError class with Object.setPrototypeOf for correct instanceof
- Add handleAuthResponse helper: throws SessionExpiredError on 401/opaqueredirect, generic Error on other non-ok
- Add redirect:'manual' + handleAuthResponse to all six fetch wrappers (fetchEvents, createEvent, updateEvent, deleteEvent, fetchSyncStatus, fetchWritableCalendars)
- Unify fetchMe: now throws SessionExpiredError instead of generic Error
- Add recurrenceUntil? and recurrenceCount? to CreateEventPayload (D-06)
- Add hasRrule: boolean to CalendarOccurrence client mirror (D-08, Pitfall 4)