Calendars as entities, uniform widget contract, multi-dashboard tasks

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.
This commit is contained in:
ginnoir
2026-05-06 01:14:49 -05:00
parent 35a14b81c9
commit 09d6adbf18
10 changed files with 370 additions and 64 deletions
+74 -27
View File
@@ -2,56 +2,103 @@
## Goal
Implement the `calendar` module: shared events with month/week/day views and CRUD.
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), 07 (household)
- 04 (module loader + widget contract), 07 (household), 08 (theming)
## Scope
### Schema (`src/modules/calendar/schema.ts`)
- `calendar_events`:
- `id` uuid pk
- `household_id` fk
- `title` text
- `start_at` timestamptz, `end_at` timestamptz
- `all_day` boolean
- `location` text nullable
- `notes` text nullable
- `color` text nullable
- `owner_id` fk users
- `rrule` text nullable (reserved; no UI)
- `external_source` text nullable, `external_id` text nullable (reserved)
- `created_at`, `updated_at`
`calendars`:
- `id` uuid pk
- `household_id` fk
- `owner_id` fk users
- `name` text
- `color` text nullable
- `visibility` text — `'private' | 'household'`. Check constraint.
- `created_at`, `updated_at`
`calendar_events`:
- `id` uuid pk
- `calendar_id` fk calendars (events inherit household scope through the calendar)
- `title` text
- `start_at` timestamptz, `end_at` timestamptz
- `all_day` boolean
- `location` text nullable
- `notes` text nullable
- `owner_id` fk users
- `rrule` text nullable (reserved; no UI)
- `external_source` text nullable, `external_id` text nullable (reserved)
- `created_at`, `updated_at`
Index on `(calendar_id, start_at)` for range queries.
### Seed (in `pnpm db:seed`, idempotent)
- One `Home` calendar per household, `visibility = 'household'`.
- One `Personal` calendar per user on first login, `visibility = 'private'`.
### Visibility helper
`canSeeCalendar(userId, calendarId)`:
- `visibility = 'household'` → must be a member of the calendar's household
- `visibility = 'private'` → must be the calendar's `owner_id`
All event reads go through this. Cross-leakage tests prove it.
### Server (`src/modules/calendar/server/`)
- `listEvents({ from, to })` — household-scoped, range query.
- `createEvent`, `updateEvent`, `deleteEvent` — server actions, validate with Zod.
- `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. `createEvent` requires a `calendarId` the user can write to.
### UI (`src/modules/calendar/components/`)
- `/calendar` page with month / week / day toggle. Recommended lib: **FullCalendar** (`@fullcalendar/react` + day/week/month plugins) — handles the heavy lifting; we own only the data layer.
- Click a day cell → create-event dialog. Click an event → edit dialog. Drag-resize updates `end_at`.
- `/calendar` page 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
- Registers entity type `calendar.event` with `share` enabled (read-only by default), `reminder` enabled, `search` enabled (title + notes + location).
- Registers nav entry `/calendar`.
- Dashboard widget: next 3 days (built in task 20, but expose the data fetcher here).
- Quick-add: "New event" → opens create dialog.
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 }`. `resolveConfigOptions` returns `{ 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
- [ ] CRUD works end-to-end with optimistic updates.
- [ ] All queries scoped to `household_id`; cross-household leakage is impossible (verify with a second seeded household in a test).
- [ ] Manifest registers all four capabilities (entity, nav, widget data, quick-add).
- [ ] One Playwright happy-path test: create → see on calendar → edit → delete.
- [ ] Default seeds create one `Home` (household) calendar and one `Personal` (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 `resolveConfigOptions` adapter.
- [ ] One Playwright happy-path test: create calendar → create event in it → see on calendar → edit → delete event → delete calendar.