feat(03-12): GREEN — WR-03 blank edit, WR-03 recurrence, WR-05 zone-consistent, IN-03
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.
This commit is contained in:
@@ -68,6 +68,9 @@ vi.mock('../store/calendarStore.js', () => ({
|
|||||||
if (typeof selector === 'function') return selector(state)
|
if (typeof selector === 'function') return selector(state)
|
||||||
return 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', () => ({
|
vi.mock('../api/client.js', () => ({
|
||||||
@@ -601,17 +604,15 @@ describe('EventForm — Plan 03-12 gap closures', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── IN-03: todayIso exported from calendarStore ───────────────────────────
|
// ── IN-03: todayIso exported from calendarStore ───────────────────────────
|
||||||
// This test is structural — we verify the export exists by importing it.
|
// This test is structural — we verify the export exists in the REAL module.
|
||||||
// If todayIso is NOT exported, the import itself will cause a TypeScript/Vite
|
// The calendarStore is mocked in this file via vi.mock(), so we must use
|
||||||
// module error at test collection time, making the test file fail to load.
|
// vi.importActual() to bypass the mock and test the actual export.
|
||||||
|
|
||||||
it('IN-03: todayIso is exported from calendarStore (single source of truth)', async () => {
|
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
|
// Use importActual to bypass the vi.mock() and test the real module export
|
||||||
const storeModule = await import('../store/calendarStore.js')
|
const actualModule = await vi.importActual('../store/calendarStore.js') as Record<string, unknown>
|
||||||
// @ts-expect-error — todayIso is being added as a new export; TypeScript type not yet updated
|
expect(typeof actualModule.todayIso).toBe('function')
|
||||||
expect(typeof storeModule.todayIso).toBe('function')
|
const result = (actualModule.todayIso as () => string)()
|
||||||
// @ts-expect-error
|
|
||||||
const result = storeModule.todayIso()
|
|
||||||
// Should return a YYYY-MM-DD string
|
// Should return a YYYY-MM-DD string
|
||||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { X, Loader2 } from 'lucide-react'
|
import { X, Loader2 } from 'lucide-react'
|
||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
import { useCalendarStore, todayIso } from '../store/calendarStore.js'
|
||||||
import {
|
import {
|
||||||
createEvent,
|
createEvent,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
@@ -44,13 +44,8 @@ import type { CalendarOccurrence } from '../api/client.js'
|
|||||||
|
|
||||||
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl'
|
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl'
|
||||||
|
|
||||||
function getDefaultStartDate(): string {
|
// IN-03: todayIso is now imported from calendarStore (single source of truth).
|
||||||
return new Date().toISOString().slice(0, 10)
|
// getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites.
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultEndDate(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -80,23 +75,32 @@ function writeLastCalendarUrl(url: string): void {
|
|||||||
/**
|
/**
|
||||||
* Parse an ISO date string (possibly with time + offset) into
|
* 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.
|
* { 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 } {
|
function parseDateTime(iso: string): { date: string; time: string } {
|
||||||
try {
|
try {
|
||||||
// Strip IANA bracket suffix e.g. '[America/Toronto]'
|
// Strip IANA bracket suffix e.g. '[America/Toronto]'
|
||||||
const clean = iso.replace(/\[[^\]]*\]$/, '')
|
const clean = iso.replace(/\[[^\]]*\]$/, '')
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
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' }
|
return { date: iso, time: '09:00' }
|
||||||
}
|
}
|
||||||
const d = new Date(clean)
|
const d = new Date(clean)
|
||||||
if (isNaN(d.getTime())) throw new Error('Invalid date')
|
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 hours = String(d.getHours()).padStart(2, '0')
|
||||||
const mins = String(d.getMinutes()).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 {
|
} catch {
|
||||||
return { date: getDefaultStartDate(), time: '09:00' }
|
return { date: todayIso(), time: '09:00' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,8 +138,8 @@ export function EventForm() {
|
|||||||
|
|
||||||
// ── Form state ──────────────────────────────────────────────────────────────
|
// ── Form state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const initStart = occurrence ? parseDateTime(occurrence.start) : { date: getDefaultStartDate(), time: '09:00' }
|
const initStart = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' }
|
||||||
const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: getDefaultEndDate(), time: '10:00' }
|
const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '10:00' }
|
||||||
|
|
||||||
const [title, setTitle] = useState(occurrence?.title ?? '')
|
const [title, setTitle] = useState(occurrence?.title ?? '')
|
||||||
const [allDay, setAllDay] = useState(occurrence?.allDay ?? false)
|
const [allDay, setAllDay] = useState(occurrence?.allDay ?? false)
|
||||||
@@ -163,22 +167,34 @@ export function EventForm() {
|
|||||||
}
|
}
|
||||||
}, [writableCalendars]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (eventFormOpen) {
|
if (eventFormOpen) {
|
||||||
const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: getDefaultStartDate(), time: '09:00' }
|
const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' }
|
||||||
const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: getDefaultEndDate(), time: '10:00' }
|
const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '10:00' }
|
||||||
setTitle(occurrence?.title ?? '')
|
setTitle(occurrence?.title ?? '')
|
||||||
setAllDay(occurrence?.allDay ?? false)
|
setAllDay(occurrence?.allDay ?? false)
|
||||||
setStartDate(startParsed.date)
|
setStartDate(startParsed.date)
|
||||||
setStartTime(startParsed.time)
|
setStartTime(startParsed.time)
|
||||||
setEndDate(endParsed.date)
|
setEndDate(endParsed.date)
|
||||||
setEndTime(endParsed.time)
|
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 ?? '')
|
setLocation(occurrence?.location ?? '')
|
||||||
setDescription(occurrence?.description ?? '')
|
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 ────────────────────────────────────────────────────────
|
// ── Validation state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ function initialCalendarRange(): { start: string; end: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Today as an ISO date string (YYYY-MM-DD). */
|
/** Today as an ISO date string (YYYY-MM-DD). Exported for use in EventForm (IN-03). */
|
||||||
function todayIso(): string {
|
export function todayIso(): string {
|
||||||
return new Date().toISOString().slice(0, 10)
|
return new Date().toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user