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 -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,
},
})