From f0f1361fbac848c83f0391e8d14fb3ac33f916dd Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 20:40:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(03-12):=20GREEN=20=E2=80=94=20WR-03=20blan?= =?UTF-8?q?k=20edit,=20WR-03=20recurrence,=20WR-05=20zone-consistent,=20IN?= =?UTF-8?q?-03?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WR-03 blank: add occurrence?.uid to reset effect deps so form re-populates when occurrence resolves in TanStack cache after form opens. WR-03 recurrence: derive initial recurrence from occurrence?.recurrence instead of hard-coding 'none'; defaults to 'none' when absent (v1 comment). WR-05: rewrite parseDateTime to use getFullYear/getMonth/getDate/getHours/ getMinutes (all local accessors) — never mix toISOString() UTC date with getHours() local time. IN-03: export todayIso from calendarStore (was private); import into EventForm and collapse getDefaultStartDate/getDefaultEndDate to todayIso() calls. --- apps/pwa/src/components/EventForm.test.tsx | 19 ++++---- apps/pwa/src/components/EventForm.tsx | 54 ++++++++++++++-------- apps/pwa/src/store/calendarStore.ts | 4 +- 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/apps/pwa/src/components/EventForm.test.tsx b/apps/pwa/src/components/EventForm.test.tsx index a9cc4b3..7be13f9 100644 --- a/apps/pwa/src/components/EventForm.test.tsx +++ b/apps/pwa/src/components/EventForm.test.tsx @@ -68,6 +68,9 @@ vi.mock('../store/calendarStore.js', () => ({ if (typeof selector === 'function') return selector(state) return state }), + // IN-03: todayIso is now exported from calendarStore (gap closure); mock it here + // so EventForm can import it without errors. Returns today as YYYY-MM-DD. + todayIso: () => new Date().toISOString().slice(0, 10), })) vi.mock('../api/client.js', () => ({ @@ -601,17 +604,15 @@ describe('EventForm — Plan 03-12 gap closures', () => { }) // ── IN-03: todayIso exported from calendarStore ─────────────────────────── - // This test is structural — we verify the export exists by importing it. - // If todayIso is NOT exported, the import itself will cause a TypeScript/Vite - // module error at test collection time, making the test file fail to load. + // This test is structural — we verify the export exists in the REAL module. + // The calendarStore is mocked in this file via vi.mock(), so we must use + // vi.importActual() to bypass the mock and test the actual export. it('IN-03: todayIso is exported from calendarStore (single source of truth)', async () => { - // Dynamic import to avoid static TDZ issues and to test export presence - const storeModule = await import('../store/calendarStore.js') - // @ts-expect-error — todayIso is being added as a new export; TypeScript type not yet updated - expect(typeof storeModule.todayIso).toBe('function') - // @ts-expect-error - const result = storeModule.todayIso() + // Use importActual to bypass the vi.mock() and test the real module export + const actualModule = await vi.importActual('../store/calendarStore.js') as Record + expect(typeof actualModule.todayIso).toBe('function') + const result = (actualModule.todayIso as () => string)() // Should return a YYYY-MM-DD string expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/) }) diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index 61231d2..7bd0d3d 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -30,7 +30,7 @@ import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { X, Loader2 } from 'lucide-react' -import { useCalendarStore } from '../store/calendarStore.js' +import { useCalendarStore, todayIso } from '../store/calendarStore.js' import { createEvent, updateEvent, @@ -44,13 +44,8 @@ import type { CalendarOccurrence } from '../api/client.js' const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl' -function getDefaultStartDate(): string { - return new Date().toISOString().slice(0, 10) -} - -function getDefaultEndDate(): string { - return new Date().toISOString().slice(0, 10) -} +// IN-03: todayIso is now imported from calendarStore (single source of truth). +// getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites. // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -80,23 +75,32 @@ function writeLastCalendarUrl(url: string): void { /** * Parse an ISO date string (possibly with time + offset) into * { date: 'YYYY-MM-DD', time: 'HH:MM' }. Falls back to today/09:00 if malformed. + * + * WR-05 fix: date and time are derived from the SAME local-accessor family. + * NEVER mix toISOString().slice(0,10) (UTC date) with getHours() (local time). + * Both date and time use getFullYear/getMonth/getDate/getHours/getMinutes so + * the pair describes the same wall-clock consistently in the viewer's zone. */ function parseDateTime(iso: string): { date: string; time: string } { try { // Strip IANA bracket suffix e.g. '[America/Toronto]' const clean = iso.replace(/\[[^\]]*\]$/, '') if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) { - // All-day date string + // All-day date string — use as-is (no time component) return { date: iso, time: '09:00' } } const d = new Date(clean) if (isNaN(d.getTime())) throw new Error('Invalid date') - const date = d.toISOString().slice(0, 10) + // WR-05: use ONLY local accessors so date and time are in the same zone frame. + // Do NOT use toISOString() here — it returns UTC, which can differ from local time. + const year = String(d.getFullYear()) + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') const hours = String(d.getHours()).padStart(2, '0') const mins = String(d.getMinutes()).padStart(2, '0') - return { date, time: `${hours}:${mins}` } + return { date: `${year}-${month}-${day}`, time: `${hours}:${mins}` } } catch { - return { date: getDefaultStartDate(), time: '09:00' } + return { date: todayIso(), time: '09:00' } } } @@ -134,8 +138,8 @@ export function EventForm() { // ── Form state ────────────────────────────────────────────────────────────── - const initStart = occurrence ? parseDateTime(occurrence.start) : { date: getDefaultStartDate(), time: '09:00' } - const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: getDefaultEndDate(), time: '10:00' } + const initStart = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' } + const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '10:00' } const [title, setTitle] = useState(occurrence?.title ?? '') const [allDay, setAllDay] = useState(occurrence?.allDay ?? false) @@ -163,22 +167,34 @@ export function EventForm() { } }, [writableCalendars]) // eslint-disable-line react-hooks/exhaustive-deps - // Reset form when opening (mode may change) + // Reset form when opening (mode may change) or when occurrence resolves in cache. + // WR-03: `occurrence` (via occurrence?.uid) is in the dep array so the effect + // re-runs when the occurrence arrives in the TanStack cache after form open. + // This prevents the "blank edit form" bug when the form opens before the cache + // has hydrated the occurrence for the requested UID. useEffect(() => { if (eventFormOpen) { - const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: getDefaultStartDate(), time: '09:00' } - const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: getDefaultEndDate(), time: '10:00' } + const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' } + const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '10:00' } setTitle(occurrence?.title ?? '') setAllDay(occurrence?.allDay ?? false) setStartDate(startParsed.date) setStartTime(startParsed.time) setEndDate(endParsed.date) setEndTime(endParsed.time) - setRecurrence('none') + // WR-03 recurrence: derive from occurrence if present; default 'none' only when + // genuinely absent. Note: occurrence.recurrence is not in CalendarOccurrence type + // (the API expand contract does not expose it in v1 — D-03). We cast to any to + // read it if a future API version adds it, and default to 'none' when not present + // (WR-03 v1 comment: occurrence edits whose recurrence is not in the cache default + // to 'none'; this will be addressed when the occurrence/expand contract is extended). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined + setRecurrence(derivedRecurrence ?? 'none') setLocation(occurrence?.location ?? '') setDescription(occurrence?.description ?? '') } - }, [eventFormOpen, eventFormMode, eventFormUid]) // eslint-disable-line react-hooks/exhaustive-deps + }, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]) // eslint-disable-line react-hooks/exhaustive-deps // ── Validation state ──────────────────────────────────────────────────────── diff --git a/apps/pwa/src/store/calendarStore.ts b/apps/pwa/src/store/calendarStore.ts index 38a319f..7c07dfb 100644 --- a/apps/pwa/src/store/calendarStore.ts +++ b/apps/pwa/src/store/calendarStore.ts @@ -118,8 +118,8 @@ function initialCalendarRange(): { start: string; end: string } { } } -/** Today as an ISO date string (YYYY-MM-DD). */ -function todayIso(): string { +/** Today as an ISO date string (YYYY-MM-DD). Exported for use in EventForm (IN-03). */ +export function todayIso(): string { return new Date().toISOString().slice(0, 10) }