feat(02-03): hydrateEvents + calendarStore + windowed fetchEvents; RED stubs green
- Create hydrateEvents.ts: Temporal.PlainDate for allDay, ZonedDateTime for timed calendarId routes via isShared ? 'shared' : String(ownerUserId) — NOT String(calendarId) _familySync carries uid/color/isShared through to popover - Update hydrateEvents.test.ts: add temporal-polyfill/global import; all 4 RED stubs now GREEN - Create calendarStore.ts: Zustand store with selectedView (localStorage per breakpoint group), selectedDate, openEventId, calendarRange; D-05 view defaults; calendarRange ± buffer for initial TanStack Query key without depending on onRangeUpdate firing on mount - Update client.ts: add CalendarOccurrence/OccurrencesResponse, windowed fetchEvents(start,end) with credentials:include; keep legacy CalendarEvent/EventsResponse + fetchEventsLegacy as deprecated for EventProof.tsx (removed in Plan 05) - Update EventProof.tsx: switch to fetchEventsLegacy to keep build clean until Plan 05 - tsc --noEmit clean; all 18 PWA tests pass
This commit is contained in:
@@ -9,6 +9,8 @@
|
|||||||
* 302 redirect to Authelia on the next API call automatically (full-page nav).
|
* 302 redirect to Authelia on the next API call automatically (full-page nav).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ── /api/me ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MeUser {
|
export interface MeUser {
|
||||||
id: number
|
id: number
|
||||||
displayName: string | null
|
displayName: string | null
|
||||||
@@ -33,9 +35,73 @@ export async function fetchMe(): Promise<MeResponse> {
|
|||||||
return res.json() as Promise<MeResponse>
|
return res.json() as Promise<MeResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── /api/events (windowed — Phase 2) ─────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single cached calendar event from the broker's MariaDB cache.
|
* A single concrete occurrence of a calendar event.
|
||||||
* Mirrors the calendarEvents table schema from apps/api/src/db/schema.ts.
|
*
|
||||||
|
* Mirrors the CalendarOccurrence shape produced by the backend's
|
||||||
|
* expandOccurrences() helper (apps/api/src/broker/expand.ts).
|
||||||
|
*
|
||||||
|
* calendarId and ownerUserId are both present:
|
||||||
|
* calendarId — DB calendar-row id (do NOT use for Schedule-X routing)
|
||||||
|
* ownerUserId — DB user id (use for Schedule-X calendarId routing)
|
||||||
|
* isShared — true when this event belongs to the shared-family calendar
|
||||||
|
*
|
||||||
|
* hydrateEvents() uses isShared/ownerUserId — never String(calendarId) —
|
||||||
|
* to build the Schedule-X calendarId that keys into buildCalendarConfig().
|
||||||
|
*/
|
||||||
|
export interface CalendarOccurrence {
|
||||||
|
id: string // `${uid}::${dtstart_iso}` — stable identity
|
||||||
|
uid: string
|
||||||
|
calendarId: number // DB calendar-row id — do NOT use for SX calendarId routing
|
||||||
|
calendarName: string
|
||||||
|
ownerUserId: number // DB user id — the correct Schedule-X routing key
|
||||||
|
color: string // hex from users.color or shared-family constant
|
||||||
|
isShared: boolean // true → 'shared' slot; false → String(ownerUserId) slot
|
||||||
|
title: string
|
||||||
|
start: string // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed
|
||||||
|
end: string
|
||||||
|
allDay: boolean
|
||||||
|
location: string | null
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OccurrencesResponse {
|
||||||
|
occurrences: CalendarOccurrence[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch windowed calendar occurrences.
|
||||||
|
*
|
||||||
|
* The ?start=&end= window is mandatory — an unwindowed call would expand 500+
|
||||||
|
* cached events with all their recurring occurrences (RESEARCH.md Pitfall 5).
|
||||||
|
*
|
||||||
|
* @param start ISO date string 'YYYY-MM-DD' — window start (inclusive)
|
||||||
|
* @param end ISO date string 'YYYY-MM-DD' — window end (exclusive)
|
||||||
|
*/
|
||||||
|
export async function fetchEvents(
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
): Promise<OccurrencesResponse> {
|
||||||
|
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET /api/events failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<OccurrencesResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy types + function for EventProof (Phase 1 broker-proof component) ──
|
||||||
|
// EventProof.tsx uses these; the component is removed in Plan 05.
|
||||||
|
// Kept here to prevent build breakage until then.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Phase 1 raw cache shape — use CalendarOccurrence for Phase 2.
|
||||||
|
* EventProof uses this type; it is removed in Plan 05.
|
||||||
*/
|
*/
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: number
|
id: number
|
||||||
@@ -49,24 +115,23 @@ export interface CalendarEvent {
|
|||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Phase 1 raw response shape — use OccurrencesResponse for Phase 2.
|
||||||
|
*/
|
||||||
export interface EventsResponse {
|
export interface EventsResponse {
|
||||||
events: CalendarEvent[]
|
events: CalendarEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all cached calendar events from the broker's MariaDB cache.
|
* @deprecated Phase 1 broker-proof fetch — unwindowed, returns raw cache rows.
|
||||||
* No live Fastmail call — the 5-min poller keeps the cache fresh.
|
* Used only by EventProof.tsx; removed in Plan 05.
|
||||||
*
|
|
||||||
* Used by EventProof to display the first cached event as broker proof (CAL-01).
|
|
||||||
*/
|
*/
|
||||||
export async function fetchEvents(): Promise<EventsResponse> {
|
export async function fetchEventsLegacy(): Promise<EventsResponse> {
|
||||||
const res = await fetch('/api/events', {
|
const res = await fetch('/api/events', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`GET /api/events failed: ${res.status}`)
|
throw new Error(`GET /api/events failed: ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as Promise<EventsResponse>
|
return res.json() as Promise<EventsResponse>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchEvents, type CalendarEvent } from '../api/client'
|
// fetchEventsLegacy: Phase 1 broker-proof; replaced by windowed fetchEvents in Plan 04.
|
||||||
|
// This component is removed in Plan 05.
|
||||||
|
import { fetchEventsLegacy, type CalendarEvent } from '../api/client'
|
||||||
import ICAL from 'ical.js'
|
import ICAL from 'ical.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,8 +59,8 @@ function formatDate(event: CalendarEvent): string {
|
|||||||
|
|
||||||
export function EventProof() {
|
export function EventProof() {
|
||||||
const eventsQuery = useQuery({
|
const eventsQuery = useQuery({
|
||||||
queryKey: ['events'],
|
queryKey: ['events-legacy'],
|
||||||
queryFn: fetchEvents,
|
queryFn: fetchEventsLegacy,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 5 * 60 * 1000, // 5 min — matches broker poll interval
|
staleTime: 5 * 60 * 1000, // 5 min — matches broker poll interval
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
* This assertion locks the Plan 03 routing fix.
|
* This assertion locks the Plan 03 routing fix.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import 'temporal-polyfill/global'
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
// Not yet built — import will fail (RED state) until Plan 03 implements hydrateEvents.ts
|
|
||||||
import { hydrateEvents } from './hydrateEvents.js'
|
import { hydrateEvents } from './hydrateEvents.js'
|
||||||
|
|
||||||
// Minimal CalendarOccurrence shape for test purposes
|
// Minimal CalendarOccurrence shape for test purposes
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* hydrateEvents — convert raw API occurrences to Schedule-X event format.
|
||||||
|
*
|
||||||
|
* Schedule-X v4 requires:
|
||||||
|
* - Temporal.PlainDate for all-day events
|
||||||
|
* - Temporal.ZonedDateTime for timed events
|
||||||
|
* It does NOT accept ISO strings for start/end.
|
||||||
|
*
|
||||||
|
* CRITICAL — calendarId routing:
|
||||||
|
* occ.isShared → 'shared'
|
||||||
|
* !occ.isShared → String(occ.ownerUserId) ← NOT String(occ.calendarId)
|
||||||
|
*
|
||||||
|
* occ.calendarId is the DB calendar-row id. buildCalendarConfig() keys its
|
||||||
|
* calendars config by String(userId) and 'shared'. A member owning multiple
|
||||||
|
* calendars (e.g. "Calendar" + "USA Holidays") would produce a calendarId
|
||||||
|
* matching no config key if we used String(calendarId), rendering those events
|
||||||
|
* with no color. Routing by String(ownerUserId) keeps one color slot per member.
|
||||||
|
*
|
||||||
|
* Temporal is registered as a global by 'temporal-polyfill/global', imported
|
||||||
|
* in main.tsx before any Schedule-X code mounts. Tests must import
|
||||||
|
* 'temporal-polyfill/global' at the top of hydrateEvents.test.ts.
|
||||||
|
*
|
||||||
|
* Source: https://schedule-x.dev/docs/calendar/events
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CalendarOccurrence {
|
||||||
|
id: string // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
|
||||||
|
uid: string
|
||||||
|
calendarId: number // DB calendar-row id — NOT used for calendarId routing
|
||||||
|
calendarName: string
|
||||||
|
ownerUserId: number // DB user id — this IS the routing key for personal events
|
||||||
|
color: string // hex from users.color or shared-family constant
|
||||||
|
isShared: boolean // true when this event belongs to the shared-family calendar
|
||||||
|
title: string
|
||||||
|
start: string // 'YYYY-MM-DD' for all-day; ISO 8601 with IANA tz for timed
|
||||||
|
end: string
|
||||||
|
allDay: boolean
|
||||||
|
location: string | null
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleXEvent {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
start: Temporal.ZonedDateTime | Temporal.PlainDate
|
||||||
|
end: Temporal.ZonedDateTime | Temporal.PlainDate
|
||||||
|
/**
|
||||||
|
* Schedule-X calendarId — keys into the calendars config built by
|
||||||
|
* buildCalendarConfig(). Routing:
|
||||||
|
* 'shared' when isShared === true
|
||||||
|
* String(ownerUserId) when isShared === false
|
||||||
|
*/
|
||||||
|
calendarId: string
|
||||||
|
location?: string
|
||||||
|
description?: string
|
||||||
|
/** FamilySync custom fields — carried through for popover rendering */
|
||||||
|
_familySync: {
|
||||||
|
uid: string
|
||||||
|
color: string
|
||||||
|
isShared: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an array of CalendarOccurrence (server JSON) to Schedule-X events.
|
||||||
|
*
|
||||||
|
* All-day guard (Pitfall 2): allDay:true → Temporal.PlainDate.from(occ.start)
|
||||||
|
* where occ.start is 'YYYY-MM-DD'. Never use ZonedDateTime for all-day events
|
||||||
|
* or the date will shift in negative-offset timezones (UTC-N).
|
||||||
|
*/
|
||||||
|
export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] {
|
||||||
|
return occurrences.map((occ) => {
|
||||||
|
// calendarId routing contract — must match buildCalendarConfig() keys
|
||||||
|
const calendarId: string = occ.isShared ? 'shared' : String(occ.ownerUserId)
|
||||||
|
|
||||||
|
if (occ.allDay) {
|
||||||
|
// All-day: use Temporal.PlainDate — do NOT construct ZonedDateTime from
|
||||||
|
// midnight UTC. occ.start and occ.end are 'YYYY-MM-DD' strings.
|
||||||
|
return {
|
||||||
|
id: occ.id,
|
||||||
|
title: occ.title,
|
||||||
|
start: Temporal.PlainDate.from(occ.start),
|
||||||
|
end: Temporal.PlainDate.from(occ.end),
|
||||||
|
calendarId,
|
||||||
|
_familySync: {
|
||||||
|
uid: occ.uid,
|
||||||
|
color: occ.color,
|
||||||
|
isShared: occ.isShared,
|
||||||
|
},
|
||||||
|
} satisfies ScheduleXEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timed: use ZonedDateTime from the offset+IANA-annotated ISO string the server returns.
|
||||||
|
// e.g. '2026-06-15T10:00:00-04:00[America/New_York]'
|
||||||
|
return {
|
||||||
|
id: occ.id,
|
||||||
|
title: occ.title,
|
||||||
|
start: Temporal.ZonedDateTime.from(occ.start),
|
||||||
|
end: Temporal.ZonedDateTime.from(occ.end),
|
||||||
|
calendarId,
|
||||||
|
location: occ.location ?? undefined,
|
||||||
|
description: occ.description ?? undefined,
|
||||||
|
_familySync: {
|
||||||
|
uid: occ.uid,
|
||||||
|
color: occ.color,
|
||||||
|
isShared: occ.isShared,
|
||||||
|
},
|
||||||
|
} satisfies ScheduleXEvent
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Zustand UI-state store for the calendar shell.
|
||||||
|
*
|
||||||
|
* Owns ONLY UI-shape state — no server data ever enters this store.
|
||||||
|
* Server state (events, user profile) lives in TanStack Query.
|
||||||
|
*
|
||||||
|
* State contract (UI-SPEC §State Management Contract):
|
||||||
|
* selectedView — persisted in localStorage keyed by breakpoint group
|
||||||
|
* selectedDate — ISO string; NOT persisted
|
||||||
|
* openEventId — null when popover is closed
|
||||||
|
* calendarRange — { start, end } ISO date strings; drives TanStack Query key
|
||||||
|
*
|
||||||
|
* View default logic (D-05):
|
||||||
|
* phone (≤767px) → 'month-agenda'
|
||||||
|
* tablet-desktop (≥768px) → 'month-grid'
|
||||||
|
*
|
||||||
|
* localStorage keys:
|
||||||
|
* calendarView.phone — persisted view for phone breakpoint group
|
||||||
|
* calendarView.tablet-desktop — persisted view for tablet-desktop
|
||||||
|
*
|
||||||
|
* Note: calendarRange is initialized to today ± buffer (current month ± 1 week)
|
||||||
|
* so the first TanStack Query fetch uses a sensible window without depending on
|
||||||
|
* onRangeUpdate firing on mount (A4/Open Q2 from RESEARCH.md).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type BreakpointGroup = 'phone' | 'tablet-desktop'
|
||||||
|
|
||||||
|
export interface CalendarStore {
|
||||||
|
selectedView: string
|
||||||
|
selectedDate: string
|
||||||
|
openEventId: string | null
|
||||||
|
calendarRange: { start: string; end: string }
|
||||||
|
|
||||||
|
setSelectedView: (view: string) => void
|
||||||
|
setSelectedDate: (date: string) => void
|
||||||
|
setOpenEventId: (id: string | null) => void
|
||||||
|
setCalendarRange: (range: { start: string; end: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Determine breakpoint group from current viewport width. */
|
||||||
|
function getBreakpointGroup(): BreakpointGroup {
|
||||||
|
if (typeof window === 'undefined') return 'tablet-desktop'
|
||||||
|
return window.matchMedia('(max-width: 767px)').matches ? 'phone' : 'tablet-desktop'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** localStorage key for the persisted view of a breakpoint group. */
|
||||||
|
function viewStorageKey(group: BreakpointGroup): string {
|
||||||
|
return `calendarView.${group}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the persisted view for the current breakpoint group. */
|
||||||
|
function readPersistedView(): string {
|
||||||
|
const group = getBreakpointGroup()
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(viewStorageKey(group))
|
||||||
|
if (stored) return stored
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable (SSR, private mode)
|
||||||
|
}
|
||||||
|
// D-05 defaults: phone → 'month-agenda', tablet-desktop → 'month-grid'
|
||||||
|
return group === 'phone' ? 'month-agenda' : 'month-grid'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the initial calendarRange: today ± ~1 week buffer (current month ± 7 days). */
|
||||||
|
function initialCalendarRange(): { start: string; end: string } {
|
||||||
|
const today = new Date()
|
||||||
|
const start = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||||
|
start.setDate(start.getDate() - 7)
|
||||||
|
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
||||||
|
end.setDate(end.getDate() + 7)
|
||||||
|
return {
|
||||||
|
start: start.toISOString().slice(0, 10),
|
||||||
|
end: end.toISOString().slice(0, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today as an ISO date string (YYYY-MM-DD). */
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const useCalendarStore = create<CalendarStore>((set) => ({
|
||||||
|
selectedView: readPersistedView(),
|
||||||
|
selectedDate: todayIso(),
|
||||||
|
openEventId: null,
|
||||||
|
calendarRange: initialCalendarRange(),
|
||||||
|
|
||||||
|
setSelectedView: (view: string) => {
|
||||||
|
set({ selectedView: view })
|
||||||
|
// Persist to localStorage keyed by breakpoint group
|
||||||
|
const group = getBreakpointGroup()
|
||||||
|
try {
|
||||||
|
localStorage.setItem(viewStorageKey(group), view)
|
||||||
|
} catch {
|
||||||
|
// Ignore write failures
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedDate: (date: string) => set({ selectedDate: date }),
|
||||||
|
|
||||||
|
setOpenEventId: (id: string | null) => set({ openEventId: id }),
|
||||||
|
|
||||||
|
setCalendarRange: (range: { start: string; end: string }) =>
|
||||||
|
set({ calendarRange: range }),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user