feat(02-01): schema columns, PWA vitest harness, ICS fixtures, RED test stubs

- Add calendarEvents.hasRrule boolean + idx_calendar_events_has_rrule index (Phase 2 pre-filter)
- Add calendars.isShared boolean for shared-family calendar identification
- Create apps/pwa/vitest.config.ts with jsdom environment
- Add vitest, @testing-library/react, jsdom, @testing-library/jest-dom to PWA devDependencies
- Add "test": "vitest run" script to apps/pwa/package.json
- Create three ICS fixtures: weekly-dst.ics (DST spanning), allday-birthday.ics, exdate-series.ics
- Create RED test stub expand.test.ts with concrete DST wall-clock assertions (10:00 local both sides of March 2026 boundary)
- Create RED test stub events.test.ts with 400 validation and color/isShared field contracts
- Create RED test stub hydrateEvents.test.ts with Temporal type and calendarId routing contracts (shared→"shared", personal→String(ownerUserId))
- Create RED test stub calendarConfig.test.ts with firstDayOfWeek 0→7 translation contract
This commit is contained in:
Lucas Berger
2026-06-05 09:29:45 -04:00
parent 62ebb1f9d4
commit 75252eb08c
11 changed files with 971 additions and 4 deletions
+7
View File
@@ -55,6 +55,7 @@ export const memberCredentials = mysqlTable(
/**
* Calendar collections discovered via CalDAV PROPFIND.
* One row per calendar per member. ctag/syncToken track change state for polling (D-13).
* isShared: true when this calendar is the shared-family calendar (marked by operator).
*/
export const calendars = mysqlTable(
'calendars',
@@ -69,6 +70,7 @@ export const calendars = mysqlTable(
ctag: varchar('ctag', { length: 512 }),
syncToken: varchar('sync_token', { length: 1024 }),
lastSyncedAt: timestamp('last_synced_at'),
isShared: boolean('is_shared').default(false).notNull(),
},
(t) => [index('idx_calendars_user_id').on(t.userId)],
)
@@ -94,11 +96,16 @@ export const calendarEvents = mysqlTable(
dtstartUtc: timestamp('dtstart_utc'), // NULL for all-day events
dtstartDate: date('dtstart_date'), // set for all-day events; NULL for timed
allDay: boolean('all_day').default(false).notNull(),
// hasRrule: pre-computed flag for SQL pre-filtering of recurring event masters.
// Events with hasRrule=true have dtstartUtc potentially years before any window,
// so the windowed query must include them regardless of dtstartUtc range (see RESEARCH.md §Pitfall 5).
hasRrule: boolean('has_rrule').default(false).notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [
index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc),
index('idx_calendar_events_dtstart_date').on(t.dtstartDate),
index('idx_calendar_events_has_rrule').on(t.hasRrule),
// uid is unique per calendar (idempotency key for broker upsert)
unique('uniq_calendar_uid').on(t.calendarId, t.uid),
],
+141
View File
@@ -0,0 +1,141 @@
/**
* RED test stubs for expandOccurrences() — Wave 0 state.
*
* These tests encode the concrete behavioral contracts that Plan 02 (GREEN phase) must satisfy.
* All tests reference the not-yet-built module apps/api/src/broker/expand.ts and are expected
* to fail until that module is implemented.
*
* Contracts locked here:
* 1. DST wall-clock correctness: occurrences in America/New_York must show 10:00 local time
* on BOTH sides of the March 2026 EST→EDT boundary (not shifted ±1h by UTC fallback).
* 2. All-day events return allDay:true and start as 'YYYY-MM-DD' with no time component.
* 3. EXDATE exclusions reduce the returned array by exactly one occurrence.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
// Not yet built — import will fail (RED state) until Plan 02 implements expand.ts
import { expandOccurrences } from '../../src/broker/expand.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const FIXTURES = join(__dirname, '../fixtures')
function loadFixture(name: string): string {
return readFileSync(join(FIXTURES, name), 'utf8')
}
describe('expandOccurrences — RED stubs (Wave 0)', () => {
describe('DST correctness — weekly-dst.ics', () => {
it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => {
// The fixture has DTSTART;TZID=America/New_York:20260301T100000 RRULE:FREQ=WEEKLY.
// March 8 2026 is the DST transition (clocks spring forward at 02:00).
// Occurrences on 2026-03-01 (EST, UTC-5) and 2026-03-08 (DST transition day) and
// 2026-03-15 (EDT, UTC-4) must ALL show hour === 10 in America/New_York.
// A broken implementation that falls back to UTC would show hour === 10 UTC before
// transition and hour === 11 local after transition — off by one DST hour.
const rawVevent = loadFixture('weekly-dst.ics')
const windowStart = new Date('2026-03-01T00:00:00Z')
const windowEnd = new Date('2026-04-01T00:00:00Z')
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1, // calendarId
'My Calendar', // calendarName
1, // ownerUserId
'#4A90D9', // color
false, // isShared
)
// Should return several weekly occurrences in March
expect(occurrences.length).toBeGreaterThan(0)
// The key contract: every occurrence must have local hour === 10 in America/New_York.
// We verify this by checking the ISO string offset — before DST: offset is -05:00
// (so 10:00-05:00 = 15:00 UTC); after DST: offset is -04:00 (10:00-04:00 = 14:00 UTC).
// Both are valid as long as the LOCAL wall-clock hour is 10.
for (const occ of occurrences) {
expect(occ.allDay).toBe(false)
// start must be an offset-aware ISO string: '2026-03-01T10:00:00-05:00' or similar
expect(occ.start).toMatch(/T10:00:00/)
}
// Explicitly check one pre-transition occurrence (EST) and one post-transition (EDT)
const preTransition = occurrences.find(o => o.start.includes('2026-03-01'))
const postTransition = occurrences.find(o => o.start.includes('2026-03-15'))
expect(preTransition).toBeDefined()
expect(postTransition).toBeDefined()
// Pre-transition occurrence: EST offset -05:00
expect(preTransition!.start).toContain('T10:00:00')
// Post-transition occurrence: EDT offset -04:00
expect(postTransition!.start).toContain('T10:00:00')
})
})
describe('All-day events — allday-birthday.ics', () => {
it('returns allDay:true with start as YYYY-MM-DD and no time component', () => {
// The fixture has DTSTART;VALUE=DATE:20260615 with RRULE:FREQ=YEARLY.
// The all-day occurrence should have allDay:true and start === '2026-06-15' (DATE format).
// A broken implementation returning '2026-06-15T00:00:00Z' would fail on date-shift.
const rawVevent = loadFixture('allday-birthday.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-07-01T00:00:00Z')
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1,
'My Calendar',
1,
'#4A90D9',
false,
)
expect(occurrences.length).toBe(1)
const occ = occurrences[0]
expect(occ.allDay).toBe(true)
// start must be plain date string 'YYYY-MM-DD' — no 'T' time component
expect(occ.start).toBe('2026-06-15')
expect(occ.start).not.toContain('T')
})
})
describe('EXDATE exclusions — exdate-series.ics', () => {
it('omits the EXDATE-excluded occurrence (array length is one fewer than un-excluded)', () => {
// The fixture has RRULE:FREQ=WEEKLY;COUNT=5 with EXDATE for the June 15 occurrence.
// Without EXDATE: 5 occurrences (Jun 1, Jun 8, Jun 15, Jun 22, Jun 29).
// With EXDATE on Jun 15: 4 occurrences returned.
// ICAL.RecurExpansion handles EXDATE internally — no manual filtering needed.
const rawVevent = loadFixture('exdate-series.ics')
const windowStart = new Date('2026-06-01T00:00:00Z')
const windowEnd = new Date('2026-07-01T00:00:00Z')
const occurrences = expandOccurrences(
rawVevent,
windowStart,
windowEnd,
1,
'My Calendar',
1,
'#4A90D9',
false,
)
// 5 total occurrences minus 1 EXDATE = 4
expect(occurrences.length).toBe(4)
// The June 15 occurrence must be absent
const june15 = occurrences.find(o => o.start.includes('2026-06-15'))
expect(june15).toBeUndefined()
})
})
})
+11
View File
@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FamilySync//Test//EN
BEGIN:VEVENT
UID:birthday-annual@familysync.test
DTSTART;VALUE=DATE:20260615
RRULE:FREQ=YEARLY
SUMMARY:Birthday
DESCRIPTION:Annual birthday event - all-day, no time component
END:VEVENT
END:VCALENDAR
+30
View File
@@ -0,0 +1,30 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FamilySync//Test//EN
BEGIN:VTIMEZONE
TZID:America/New_York
BEGIN:STANDARD
DTSTART:19701101T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700308T020000
RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
TZNAME:EDT
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:weekly-exdate-series@familysync.test
DTSTART;TZID=America/New_York:20260601T090000
RRULE:FREQ=WEEKLY;COUNT=5
EXDATE;TZID=America/New_York:20260615T090000
SUMMARY:Weekly Series With Exdate
DESCRIPTION:Weekly series with the June 15 occurrence excluded via EXDATE
DURATION:PT30M
END:VEVENT
END:VCALENDAR
+29
View File
@@ -0,0 +1,29 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FamilySync//Test//EN
BEGIN:VTIMEZONE
TZID:America/New_York
BEGIN:STANDARD
DTSTART:19701101T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700308T020000
RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
TZNAME:EDT
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:weekly-meeting-dst@familysync.test
DTSTART;TZID=America/New_York:20260301T100000
RRULE:FREQ=WEEKLY
SUMMARY:Weekly Meeting DST Test
DESCRIPTION:Weekly meeting spanning the March 2026 America/New_York EST to EDT transition
DURATION:PT1H
END:VEVENT
END:VCALENDAR
+68
View File
@@ -0,0 +1,68 @@
/**
* RED test stubs for GET /api/events — Wave 0 state.
*
* These tests encode the contract for the evolved windowed /api/events route.
* They reference the current events route which does not yet support windowed queries,
* color joins, or the isShared flag — all tests are expected to fail (RED) until Plan 02.
*
* Contracts locked here:
* 1. Missing/malformed start or end params → 400 (input validation guard)
* 2. Valid window returns occurrences each with a color field and isShared flag
*/
import { describe, it, expect, vi } from 'vitest'
// Mock DB to avoid real DB connections in unit tests
vi.mock('../../src/db/client.js', () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
where: vi.fn().mockResolvedValue([]),
}),
}),
},
}))
describe('GET /api/events', () => {
it('returns 400 when start param is missing', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events?end=2026-07-01')
expect(res.status).toBe(400)
})
it('returns 400 when end param is missing', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events?start=2026-06-01')
expect(res.status).toBe(400)
})
it('returns 400 when start param is malformed (not YYYY-MM-DD)', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events?start=not-a-date&end=2026-07-01')
expect(res.status).toBe(400)
})
it('returns occurrences with color and isShared fields for valid window', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01')
// Route not yet evolved — currently returns raw events without windowing or color join.
// This test will pass once Plan 02 implements the windowed query + expansion.
expect(res.status).toBe(200)
const body = await res.json() as { occurrences: Array<{ color: string; isShared: boolean }> }
expect(body).toHaveProperty('occurrences')
expect(Array.isArray(body.occurrences)).toBe(true)
// Each occurrence must carry color + isShared (may be empty array if DB is mocked empty)
for (const occ of body.occurrences) {
expect(occ).toHaveProperty('color')
expect(typeof occ.color).toBe('string')
expect(occ).toHaveProperty('isShared')
expect(typeof occ.isShared).toBe('boolean')
}
})
})
+7 -2
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "5.101.0",
@@ -17,10 +18,14 @@
"zustand": "5.0.14"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^26.1.0",
"typescript": "^5.5.0",
"vite": "8.0.16"
"vite": "8.0.16",
"vitest": "^4.1.8"
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* RED test stubs for calendarConfig — Wave 0 state.
*
* These tests encode the contracts for calendarConfig.ts.
* The tests reference the not-yet-built module apps/pwa/src/lib/calendarConfig.ts.
*
* Contracts locked here:
* 1. WEEK_START_DAY === 0 (JS/date-fns Sunday convention) → Schedule-X firstDayOfWeek === 7
* (Temporal convention: 7 = Sunday, not 0)
* Passing 0 would silently default to Monday in Schedule-X v4.
*/
import { describe, it, expect } from 'vitest'
// Not yet built — import will fail (RED state) until Plan 03 implements calendarConfig.ts
import { WEEK_START_DAY, buildCalendarConfig } from './calendarConfig.js'
describe('calendarConfig — RED stubs (Wave 0)', () => {
it('WEEK_START_DAY constant equals 0 (Sunday in JS/date-fns convention)', () => {
// The project uses 0 = Sunday (JS/date-fns convention).
// This constant is translated to 7 before passing to Schedule-X.
expect(WEEK_START_DAY).toBe(0)
})
it('WEEK_START_DAY=0 translates to Schedule-X firstDayOfWeek === 7', () => {
// Schedule-X v4 uses Temporal numbering: 1=Mon, 7=Sun.
// WEEK_START_DAY=0 (JS Sunday) must become 7 (Temporal Sunday).
// This avoids the Pitfall 1 silent failure where weeks start on Monday.
const config = buildCalendarConfig([])
expect(config.firstDayOfWeek).toBe(7)
})
it('buildCalendarConfig includes shared-family calendar with id "shared"', () => {
const config = buildCalendarConfig([])
expect(config.calendars).toHaveProperty('shared')
expect(config.calendars['shared'].colorName).toBe('shared')
})
it('buildCalendarConfig includes per-member calendars keyed by String(userId)', () => {
const members = [
{ id: '1', name: 'Lucas', color: '#4A90D9' },
{ id: '2', name: 'Spouse', color: '#E8734A' },
]
const config = buildCalendarConfig(members)
expect(config.calendars).toHaveProperty('1')
expect(config.calendars).toHaveProperty('2')
expect(config.calendars['1'].lightColors.main).toBe('#4A90D9')
expect(config.calendars['2'].lightColors.main).toBe('#E8734A')
})
})
+133
View File
@@ -0,0 +1,133 @@
/**
* RED test stubs for hydrateEvents() — Wave 0 state.
*
* These tests encode the concrete contracts that Plan 03 (GREEN phase) must satisfy.
* The tests reference the not-yet-built module apps/pwa/src/lib/hydrateEvents.ts and
* are expected to fail until that module is implemented.
*
* Contracts locked here:
* 1. All-day occurrence (allDay:true, start 'YYYY-MM-DD') → start is Temporal.PlainDate
* 2. Timed occurrence (allDay:false) → start is Temporal.ZonedDateTime
* 3. Shared occurrence (isShared:true) → Schedule-X calendarId === 'shared'
* 4. Personal occurrence (isShared:false, ownerUserId:7, calendarId:99) →
* Schedule-X calendarId === '7' (String(ownerUserId)), NOT '99' (String(calendarId))
* This assertion locks the Plan 03 routing fix.
*/
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'
// Minimal CalendarOccurrence shape for test purposes
interface TestOccurrence {
id: string
uid: string
calendarId: number
calendarName: string
ownerUserId: number
color: string
isShared: boolean
title: string
start: string
end: string
allDay: boolean
location: string | null
description: string | null
}
function makeOccurrence(overrides: Partial<TestOccurrence>): TestOccurrence {
return {
id: 'test::2026-06-15',
uid: 'test-uid',
calendarId: 1,
calendarName: 'Test Calendar',
ownerUserId: 1,
color: '#4A90D9',
isShared: false,
title: 'Test Event',
start: '2026-06-15T10:00:00-04:00[America/New_York]',
end: '2026-06-15T11:00:00-04:00[America/New_York]',
allDay: false,
location: null,
description: null,
...overrides,
}
}
describe('hydrateEvents — RED stubs (Wave 0)', () => {
describe('Temporal type conversion', () => {
it('converts all-day occurrence (allDay:true) to Temporal.PlainDate for start and end', () => {
const occurrences = [
makeOccurrence({
allDay: true,
start: '2026-06-15',
end: '2026-06-15',
}),
]
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const evt = result[0]
// Schedule-X requires Temporal.PlainDate for all-day events
expect(evt.start).toBeInstanceOf(Temporal.PlainDate)
expect(evt.end).toBeInstanceOf(Temporal.PlainDate)
})
it('converts timed occurrence (allDay:false) to Temporal.ZonedDateTime for start and end', () => {
const occurrences = [
makeOccurrence({
allDay: false,
start: '2026-06-15T10:00:00-04:00[America/New_York]',
end: '2026-06-15T11:00:00-04:00[America/New_York]',
}),
]
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const evt = result[0]
expect(evt.start).toBeInstanceOf(Temporal.ZonedDateTime)
expect(evt.end).toBeInstanceOf(Temporal.ZonedDateTime)
})
})
describe('calendarId routing — Plan 03 contract', () => {
it('shared occurrence (isShared:true) gets Schedule-X calendarId "shared"', () => {
const occurrences = [
makeOccurrence({
isShared: true,
calendarId: 5,
ownerUserId: 2,
}),
]
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
// Shared-family events must route to the 'shared' calendar slot in Schedule-X config
expect(result[0].calendarId).toBe('shared')
})
it('personal occurrence (isShared:false) gets Schedule-X calendarId === String(ownerUserId), NOT String(calendarId)', () => {
// This assertion locks the routing decision: personal events are grouped by MEMBER (ownerUserId),
// not by DB calendarId. If two calendars belong to the same user, they share the same color slot.
const occurrences = [
makeOccurrence({
isShared: false,
calendarId: 99, // DB calendar row id
ownerUserId: 7, // DB user id — this is the correct Schedule-X key
}),
]
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
// Must be '7' (String(ownerUserId)), NOT '99' (String(calendarId))
expect(result[0].calendarId).toBe('7')
expect(result[0].calendarId).not.toBe('99')
})
})
})
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
})