Architecture refinements before module work begins: - Calendars become first-class entities (mirror of lists). Each calendar has its own visibility (private | household) and is independently shareable. Updated CLAUDE.md data model and task 10. - Drop the singleton/parameterized split for dashboard widgets. Every widget declares a configSchema + defaultConfig + resolveConfigOptions and every placement is an independent instance — same widgetId can appear multiple times on one dashboard pointed at different things. Updated task 04 contract; tasks 11/12 widget sections aligned. - Add tasks 25 (multiple dashboards per user) and 26 (customizable layout + widget configuration). Replaces the previously-considered three-task plan with a cleaner two-task split that the uniform contract enables. Phase 3 index updated; STATUS records the rationale. - Task 20 reframed as a single-dashboard MVP that exercises the new contract end-to-end before the customization layer lands.
4.7 KiB
10 — Calendar module
Goal
Implement the calendar module: first-class calendars (multiple per household, each with its own visibility) and events that belong to a calendar. Month/week/day views with CRUD.
Why
Calendars are entities, not a singleton concept. A user might run a Personal (private) calendar, a Family (household-shared) calendar, and a Wedding planning calendar that gets share-linked publicly. The calendar module ships that abstraction; widgets and the share-link service then reuse it generically.
Depends on
- 04 (module loader + widget contract), 07 (household), 08 (theming)
Scope
Schema (src/modules/calendar/schema.ts)
calendars:
iduuid pkhousehold_idfkowner_idfk usersnametextcolortext nullablevisibilitytext —'private' | 'household'. Check constraint.created_at,updated_at
calendar_events:
iduuid pkcalendar_idfk calendars (events inherit household scope through the calendar)titletextstart_attimestamptz,end_attimestamptzall_daybooleanlocationtext nullablenotestext nullableowner_idfk usersrruletext nullable (reserved; no UI)external_sourcetext nullable,external_idtext nullable (reserved)created_at,updated_at
Index on (calendar_id, start_at) for range queries.
Seed (in pnpm db:seed, idempotent)
- One
Homecalendar per household,visibility = 'household'. - One
Personalcalendar per user on first login,visibility = 'private'.
Visibility helper
canSeeCalendar(userId, calendarId):
visibility = 'household'→ must be a member of the calendar's householdvisibility = 'private'→ must be the calendar'sowner_id
All event reads go through this. Cross-leakage tests prove it.
Server (src/modules/calendar/server/)
listCalendars()— every calendar the current user can see.createCalendar,renameCalendar,setCalendarVisibility,setCalendarColor,deleteCalendar.listEvents({ from, to, calendarIds })—calendarIds: 'all' | string[], where'all'resolves to every calendar the user can see; explicit ids are filtered against the visibility helper.createEvent,updateEvent,deleteEvent— server actions, validate with Zod.createEventrequires acalendarIdthe user can write to.
UI (src/modules/calendar/components/)
/calendarpage with month / week / day toggle. Recommended lib: FullCalendar (@fullcalendar/react+ day/week/month plugins).- Calendar list in a sidebar with show/hide toggles per calendar, color swatches, "+ new calendar" button. The visible set is local view state (not persisted yet — separate later concern).
- Click a day cell → create-event dialog (calendar selector defaults to last-used). Click an event → edit dialog. Drag-resize updates
end_at. /calendar/manage(or modal from sidebar) — create / rename / recolor / change visibility / delete calendars.
Manifest
Entity types:
calendar.calendar— shareable (read by default; writes via share token possible later), searchable (name).calendar.event— shareable (read-only by default), remindable, searchable (title + notes + location).
Widgets (every widget configurable per the task 04 contract):
calendar.upcoming—config: { calendarIds: "all" | string[]; days: number }. Default{ calendarIds: "all", days: 3 }.resolveConfigOptionsreturns{ calendars: { id, name, visibility }[] }.calendar.month—config: { calendarIds: "all" | string[] }. Larger default size.
Quick-add: "New event" → opens create dialog with the user's last-used calendar pre-selected. "New calendar" → calendar create dialog.
Nav: /calendar.
Out of scope
- Recurrence UI (rrule column reserved, ignored on read).
- External calendar sync.
- Free/busy aggregation across users.
- Per-user persisted "which calendars are shown" preference (local view state for now).
- Per-calendar write-share via share token (basic share link is enough for v1).
Acceptance criteria
- Default seeds create one
Home(household) calendar and onePersonal(private) calendar per user, idempotently. - CRUD for both calendars and events works end-to-end with optimistic updates.
- Private calendars are invisible to other household members in queries, in the sidebar, and in widgets.
listEvents({ calendarIds: 'all' })returns events from all visible calendars; explicit ids cannot be used to leak across visibility.- Both widgets register with the shape required by task 04 — a config schema, a default config, and a
resolveConfigOptionsadapter. - One Playwright happy-path test: create calendar → create event in it → see on calendar → edit → delete event → delete calendar.