Files
famapp/docs/tasks/10-calendar-module.md
T
ginnoir c73338e256 Code-side
src/lib/dev-login-config.ts — startup assertion: throws if NODE_ENV=production + ENABLE_DEV_LOGIN=true, scoped to runtime (skipped during next build).
Container

scripts/migrate.mjs — runs Drizzle migrations against DATABASE_URL.
deploy/docker-entrypoint.sh — runs migrations then exec node server.js. Skip with RUN_MIGRATIONS=false.
Dockerfile — copies drizzle/, scripts/migrate.mjs, entrypoint into runner stage; ENTRYPOINT now points at the script.
Compose

deploy/compose.yaml — famapp now image: ${FAMAPP_IMAGE:-ghcr.io/ginnoir/famapp:latest} (build still works locally as fallback). Authentik pinned via AUTHENTIK_IMAGE_TAG (default 2024.12.3). New RUN_MIGRATIONS env passed through.
.env.production.example — documents FAMAPP_IMAGE, AUTHENTIK_IMAGE_TAG, RUN_MIGRATIONS.
CI/CD

.github/workflows/ci.yml — push/PR: typecheck + lint + format:check + build.
.github/workflows/release.yml — v* tag: build + push ghcr.io/ginnoir/famapp:vX.Y.Z, :X.Y, :latest to GHCR.
Docs

deploy/README.md — full deploy/rollback/release runbook.
CHANGELOG.md — release log seeded with an Unreleased entry.
docs/tasks/09-pre-deploy-checklist.md — task 09 reframed from one-shot removal to a recurring pre-deploy checklist.
STATUS.md — updated.
Verified: pnpm typecheck, pnpm format, pnpm build, and docker compose config all clean.
2026-05-06 17:37:37 -05:00

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:

  • 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/)

  • 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).
  • 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.upcomingconfig: { calendarIds: "all" | string[]; days: number }. Default { calendarIds: "all", days: 3 }. resolveConfigOptions returns { calendars: { id, name, visibility }[] }.
  • calendar.monthconfig: { 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 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.