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
+6 -4
View File
@@ -60,7 +60,7 @@ src/
### Core primitives every module gets ### Core primitives every module gets
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity. - **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
- **Dashboard contribution API.** Each module exports a widget + priority. Dashboard composes whatever's installed. - **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed.
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu. - **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic. - **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic.
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy. - **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
@@ -74,15 +74,17 @@ src/
## Data model (v1) ## Data model (v1)
- `users`, `households`, `household_members` - `users`, `households`, `household_members`
- `calendar_events` — title, start, end, all_day, location, notes, color, owner. `rrule` text column reserved (no recurrence in v1). `external_source`/`external_id` nullable for future Google/Apple sync. - `calendars` — name, color, owner_id, visibility (`private` | `household`). First-class entity; users create as many as they want, each independently shareable via the share-link service.
- `lists` (type enum: `shopping` | `task` | future), `list_items` - `calendar_events``calendar_id` fk, title, start, end, all_day, location, notes, owner. `rrule` text column reserved (no recurrence in v1). `external_source`/`external_id` nullable for future Google/Apple sync.
- `lists` (type text — not enum, to allow extension), `list_items`
- `notes` — title, body, pinned, remind_at - `notes` — title, body, pinned, remind_at
- `dashboards` — per-user named dashboards (any user can have many), `layout` jsonb of placed widgets `[{ widgetId, config, x, y, w, h }]`.
- `share_links` — entity_type, entity_id, token, capabilities jsonb, expires_at - `share_links` — entity_type, entity_id, token, capabilities jsonb, expires_at
- `activity_log` — entity_type, entity_id, actor, action, payload jsonb - `activity_log` — entity_type, entity_id, actor, action, payload jsonb
- `push_subscriptions` - `push_subscriptions`
- `reminders` — entity_type, entity_id, fire_at, channel. Generic; used by notes/events/anything. - `reminders` — entity_type, entity_id, fire_at, channel. Generic; used by notes/events/anything.
All entity tables include `household_id`. All mutations are scoped to the caller's household. All household-scoped entity tables include `household_id`. Sub-entities (`calendar_events`, `list_items`) inherit scope via their parent. All reads/mutations are gated by the caller's household membership and, where relevant, per-entity visibility (e.g. private calendars).
--- ---
+7 -1
View File
@@ -15,10 +15,16 @@ Living progress tracker. Update at the end of each task. The canonical brief is
- 03 Drizzle + Postgres → 04 module loader → 05 compose/Caddy → 06 Authentik OIDC → 07 household seed → **08 theming infrastructure** (newly added; multi-theme + per-user dark/light, must land before module work so module UIs adopt the token system from day one). - 03 Drizzle + Postgres → 04 module loader → 05 compose/Caddy → 06 Authentik OIDC → 07 household seed → **08 theming infrastructure** (newly added; multi-theme + per-user dark/light, must land before module work so module UIs adopt the token system from day one).
## Recent architectural decisions
- **Calendars are first-class entities.** Like lists, users can create as many as they want with `private` or `household` visibility. Both calendar entities are independently shareable. Updated CLAUDE.md data model + task 10.
- **Uniform widget contract — no singletons.** Every dashboard widget declares a `configSchema` and `defaultConfig`; every placement is an independent instance. Same widget can appear N times on a dashboard pointed at different things. Updated task 04; tasks 11/12 widget sections updated to match.
- **Per-user customizable dashboards.** Each user can have any number of named dashboards, switch between them, drag/resize widgets, and configure each placement. Tasks 25 (multiple dashboards) and 26 (customizable layout + widget configuration) added; phase 3 index updated.
## How to resume in a fresh session ## How to resume in a fresh session
1. Open the repo root in VS Code. 1. Open the repo root in VS Code.
2. Tell Claude: *"Read [CLAUDE.md](CLAUDE.md) and [STATUS.md](STATUS.md), then complete [docs/tasks/02-nextjs-skeleton.md](docs/tasks/02-nextjs-skeleton.md). Stop at the acceptance criteria."* 2. Tell Claude: *"Read [CLAUDE.md](CLAUDE.md) and [STATUS.md](STATUS.md), then complete [docs/tasks/03-drizzle-postgres.md](docs/tasks/03-drizzle-postgres.md). Stop at the acceptance criteria."*
3. After it lands, append the result to the **Done** section here, bump **Next up**, and commit. 3. After it lands, append the result to the **Done** section here, bump **Next up**, and commit.
## Environment notes ## Environment notes
+49 -11
View File
@@ -6,7 +6,7 @@ Implement the module system that everything else hangs off: each module declares
## Why ## Why
This is the load-bearing extensibility piece. Get this wrong and every later module needs core changes. This is the load-bearing extensibility piece. Get this wrong and every later module needs core changes. The widget contract in particular must be set in stone now — modules and dashboards depend on it.
## Depends on ## Depends on
@@ -21,26 +21,60 @@ export type ModuleManifest = {
id: string; // "calendar", "lists", "notes" id: string; // "calendar", "lists", "notes"
name: string; // human-readable name: string; // human-readable
nav?: { href: string; label: string; icon?: string }; nav?: { href: string; label: string; icon?: string };
entities: EntityTypeRegistration[]; // see below entities: EntityTypeRegistration[];
dashboardWidgets?: DashboardWidget[]; dashboardWidgets?: DashboardWidget[];
quickAdds?: QuickAddAction[]; quickAdds?: QuickAddAction[];
}; };
export type EntityTypeRegistration = { export type EntityTypeRegistration = {
type: string; // "calendar.event", "lists.item", "notes.note" type: string; // "calendar.event", "calendar.calendar", "lists.list", "notes.note"
label: { singular: string; plural: string }; label: { singular: string; plural: string };
share?: ShareCapabilities; // null = not shareable share?: ShareCapabilities; // omit = not shareable
reminder?: ReminderCapabilities; // null = not remindable reminder?: ReminderCapabilities; // omit = not remindable
search?: SearchAdapter; // null = not searchable search?: SearchAdapter; // omit = not searchable
resolveUrl: (id: string) => string; // canonical app URL resolveUrl: (id: string) => string;
loadForShare?: (id: string) => Promise<unknown>; // payload for /s/<token> loadForShare?: (id: string) => Promise<unknown>;
}; };
``` ```
### Widget contract — uniform, configurable, reusable
Every widget — no singletons, no exceptions — declares a config schema. The schema can be small, but the shape is the same. Every placement on a dashboard is an independent instance with its own config; the same widget can appear N times on the same dashboard pointed at different things.
```ts
export type DashboardWidget = {
id: string; // "calendar.upcoming", "lists.list", "core.activity"
title: string; // shown in picker + drag handle
description: string; // shown in picker
category?: string; // grouping in picker
defaultSize: { w: number; h: number };
minSize?: { w: number; h: number };
maxSize?: { w: number; h: number };
defaultPriority: number; // initial seed order on a fresh dashboard
configSchema: ZodSchema; // always present; may be empty (z.object({}))
defaultConfig: unknown; // returned by the picker when a user adds the widget
resolveConfigOptions?: (ctx: WidgetContext) => Promise<unknown>;
// returns whatever the configurator UI needs (e.g. the user's accessible
// calendars). Called by the picker; not by render.
render: (props: { config: unknown; ctx: WidgetContext }) => ReactNode;
};
export type WidgetContext = {
userId: string;
householdId: string;
};
```
Conventions every parameterized widget follows:
- Selection fields use the standardized `"all" | string[]` pattern (e.g. `calendarIds: "all" | string[]`). `"all"` means "every instance the user can access". Default config = `"all"`.
- Render must tolerate any config the schema accepts — an empty selection is "show empty state with a 'configure' link".
### Registry (`src/modules/_core/registry.ts`) ### Registry (`src/modules/_core/registry.ts`)
- `registerModule(manifest)` and `getRegistry()` returning frozen views. - `registerModule(manifest)`, `getRegistry()` returning frozen views.
- `getEntityType(type)` lookup. - `getEntityType(type)` lookup.
- `getWidget(id)` lookup.
- Import-time side-effect free: modules are listed and registered explicitly in `src/modules/index.ts`. - Import-time side-effect free: modules are listed and registered explicitly in `src/modules/index.ts`.
### Loader (`src/modules/index.ts`) ### Loader (`src/modules/index.ts`)
@@ -51,11 +85,12 @@ export type EntityTypeRegistration = {
### Wiring ### Wiring
- Root layout reads the registry to render nav. - Root layout reads the registry to render nav.
- A throwaway `/debug/registry` page (dev-only) dumps the loaded registry as JSON. - A throwaway `/debug/registry` page (dev-only) dumps the loaded registry as JSON, including every widget's id, config schema (rendered via `zod-to-json-schema`), and default config.
## Out of scope ## Out of scope
- Real implementations of share / reminder / search adapters (later phases). - Real implementations of share / reminder / search adapters (later phases).
- Real widget rendering (modules ship those when they're built).
- Dynamic plugin loading from disk. Modules are statically imported. - Dynamic plugin loading from disk. Modules are statically imported.
## Acceptance criteria ## Acceptance criteria
@@ -63,10 +98,13 @@ export type EntityTypeRegistration = {
- [ ] `src/modules/index.ts` registers three stub modules. - [ ] `src/modules/index.ts` registers three stub modules.
- [ ] Nav renders entries from the registry, not from a hardcoded list. - [ ] Nav renders entries from the registry, not from a hardcoded list.
- [ ] `/debug/registry` shows all three module manifests in dev. - [ ] `/debug/registry` shows all three module manifests in dev.
- [ ] Adding a fourth stub module only requires creating its folder + adding one line to `src/modules/index.ts`. - [ ] Adding a fourth stub module requires only creating its folder + adding one line to `src/modules/index.ts`.
- [ ] All types exported from `_core` so other modules can import without circulars. - [ ] All types exported from `_core` so other modules can import without circulars.
- [ ] No widget is special-cased in `_core` — the registry treats every widget uniformly.
## Notes ## Notes
- Use `zod` for `configSchema`. Pin a single major version across the repo so types stay compatible.
- `resolveConfigOptions` is for the picker's data, never for rendering. Keep it cheap (it runs on add-widget); render is what fetches displayed data.
- Frozen objects (`Object.freeze`) on registry exposure are cheap insurance against accidental mutation. - Frozen objects (`Object.freeze`) on registry exposure are cheap insurance against accidental mutation.
- Resist the urge to make this a fancy DI container. A plain map is enough. - Resist the urge to make this a fancy DI container. A plain map is enough.
+74 -27
View File
@@ -2,56 +2,103 @@
## Goal ## 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 ## Depends on
- 04 (module loader), 07 (household) - 04 (module loader + widget contract), 07 (household), 08 (theming)
## Scope ## Scope
### Schema (`src/modules/calendar/schema.ts`) ### Schema (`src/modules/calendar/schema.ts`)
- `calendar_events`: `calendars`:
- `id` uuid pk
- `household_id` fk - `id` uuid pk
- `title` text - `household_id` fk
- `start_at` timestamptz, `end_at` timestamptz - `owner_id` fk users
- `all_day` boolean - `name` text
- `location` text nullable - `color` text nullable
- `notes` text nullable - `visibility` text — `'private' | 'household'`. Check constraint.
- `color` text nullable - `created_at`, `updated_at`
- `owner_id` fk users
- `rrule` text nullable (reserved; no UI) `calendar_events`:
- `external_source` text nullable, `external_id` text nullable (reserved)
- `created_at`, `updated_at` - `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/`) ### Server (`src/modules/calendar/server/`)
- `listEvents({ from, to })` — household-scoped, range query. - `listCalendars()` — every calendar the current user can see.
- `createEvent`, `updateEvent`, `deleteEvent` — server actions, validate with Zod. - `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/`) ### 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. - `/calendar` page with month / week / day toggle. Recommended lib: **FullCalendar** (`@fullcalendar/react` + day/week/month plugins).
- Click a day cell → create-event dialog. Click an event → edit dialog. Drag-resize updates `end_at`. - 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 ### Manifest
- Registers entity type `calendar.event` with `share` enabled (read-only by default), `reminder` enabled, `search` enabled (title + notes + location). Entity types:
- Registers nav entry `/calendar`.
- Dashboard widget: next 3 days (built in task 20, but expose the data fetcher here). - `calendar.calendar` — shareable (read by default; writes via share token possible later), searchable (name).
- Quick-add: "New event" → opens create dialog. - `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 ## Out of scope
- Recurrence UI (rrule column reserved, ignored on read). - Recurrence UI (rrule column reserved, ignored on read).
- External calendar sync. - External calendar sync.
- Free/busy aggregation across users. - 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 ## Acceptance criteria
- [ ] CRUD works end-to-end with optimistic updates. - [ ] Default seeds create one `Home` (household) calendar and one `Personal` (private) calendar per user, idempotently.
- [ ] All queries scoped to `household_id`; cross-household leakage is impossible (verify with a second seeded household in a test). - [ ] CRUD for both calendars and events works end-to-end with optimistic updates.
- [ ] Manifest registers all four capabilities (entity, nav, widget data, quick-add). - [ ] Private calendars are invisible to other household members in queries, in the sidebar, and in widgets.
- [ ] One Playwright happy-path test: create → see on calendar → edit → delete. - [ ] `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.
+4 -3
View File
@@ -30,9 +30,10 @@ Seed: one default list of each type per household on first access (idempotent).
### Manifest ### Manifest
- Entity type `lists.list` (shareable read+write via share token), `lists.item` (not directly shareable; inherits via list). - Entity types: `lists.list` (shareable read+write via share token), `lists.item` (inherits via list, not directly shareable).
- Quick-adds: "Add to shopping", "Add to tasks" (each adds a single item to the default list of that type). - Quick-adds: "Add to shopping" / "Add to tasks" (single item to the household's default list of that type), "New list".
- Dashboard widget: top 5 unchecked shopping items + open tasks assigned to me. - Widget (uniform contract per task 04):
- **`lists.list`** — `config: { listIds: "all" | string[]; showCompleted: boolean; limit?: number }`. Default `{ listIds: "all", showCompleted: false }`. `resolveConfigOptions` returns the user's accessible lists with type/name. Multiple instances per dashboard supported (one per list, or merged across several).
## Out of scope ## Out of scope
+3 -2
View File
@@ -14,8 +14,9 @@ Lightweight shared notes with optional reminders.
- `/notes` index: pinned first, then by updated_at desc. - `/notes` index: pinned first, then by updated_at desc.
- `/notes/[id]` editor — minimal markdown (use `@uiw/react-md-editor` or similar; live preview optional). - `/notes/[id]` editor — minimal markdown (use `@uiw/react-md-editor` or similar; live preview optional).
- `remind_at` writes a row to `reminders` (table comes from `_core`; reminder firing comes from task 41). - `remind_at` writes a row to `reminders` (table comes from `_core`; reminder firing comes from task 41).
- Manifest: shareable, remindable, searchable (title + body). - Manifest:
- Dashboard widget: pinned notes. - Entity `notes.note` — shareable, remindable, searchable (title + body).
- Widget (uniform contract per task 04): **`notes.filtered`** — `config: { filter: "pinned" | "all"; limit?: number }`. Default `{ filter: "pinned", limit: 10 }`. `resolveConfigOptions` returns nothing yet (placeholder for future tag filtering).
## Out of scope ## Out of scope
+64 -15
View File
@@ -1,33 +1,82 @@
# 20 — Dashboard composition # 20 — Dashboard composition (single-dashboard MVP)
## Goal ## Goal
The `/` landing page composes widgets contributed by installed modules. No hardcoded widget list. Each user sees a single default dashboard with widgets pre-seeded from the registry. Every widget is rendered with a `config` (per the task 04 contract). No customization yet — task 25/26 add multiple dashboards and the editor.
## Why
Lands a working dashboard quickly so the app is usable while the customization layer is built. Validates the configurable widget contract end-to-end. The schema introduced here (one row per user) gets generalized in task 25 to support many dashboards per user.
## Depends on ## Depends on
- 10, 11, 12 - 10 (calendar), 11 (lists), 12 (notes)
## Scope ## Scope
- `DashboardWidget` type already in `_core` (from task 04). Each widget exports `{ id, priority, render }`. ### Schema (interim, generalized in task 25)
- `/` reads `getRegistry().dashboardWidgets`, sorts by priority desc, renders them in a responsive grid (1 col mobile, 2-3 cols desktop).
- Widgets implemented: Add a `default_dashboard_layout` jsonb column on `users`. Shape:
- **calendar.upcoming** — next 3 days of events
- **lists.shopping** — top 5 unchecked items in default shopping list ```jsonc
- **lists.tasks** — my open tasks (assigned to me, due soon first) {
- **notes.pinned** — pinned notes "version": 1,
- **core.activity** — recent activity feed (depends on task 22; until then render a stub) "widgets": [
- Skeleton loaders while widgets load (each widget is a server component with `loading.tsx`-equivalent Suspense boundary). {
"widgetId": "calendar.upcoming",
"config": { "calendarIds": "all", "days": 3 },
"x": 0,
"y": 0,
"w": 6,
"h": 3,
},
{
"widgetId": "lists.list",
"config": { "listIds": "all", "showCompleted": false },
"x": 6,
"y": 0,
"w": 6,
"h": 3,
},
],
}
```
If null, fall back to the registry's default seed (sorted by `defaultPriority`, packed by `defaultSize`).
### Layout
- 12-col responsive grid using basic CSS Grid (no drag library yet — task 26 introduces it).
- Mobile: single column ordered by `y` then `x`.
- Each widget is a server component that receives `config` and renders inside a card. Suspense boundary per widget so they load in parallel.
### Seeded widgets the registry must contribute
These are widget definitions, not hardcoded slots:
- `calendar.upcoming` (calendar module)
- `calendar.month` (calendar module)
- `lists.list` (lists module)
- `notes.filtered` (notes module)
- `core.activity` (\_core; depends on task 22 — until then render a placeholder card)
### Header
- Page title "Dashboard".
- "+" floating action button opens the quick-add menu (task 21). - "+" floating action button opens the quick-add menu (task 21).
- _No_ edit-mode toggle yet — that lands in task 26.
## Out of scope ## Out of scope
- User-configurable widget order or hide/show (defer; pin a sane default order). - Multiple dashboards (task 25).
- Drag-rearrange. - Drag, resize, picker, configure UI (task 26).
- User-configurable widget order. Defaults are taken from `defaultPriority`.
## Acceptance criteria ## Acceptance criteria
- [ ] First-time user lands on a populated dashboard with widgets seeded from the registry's `defaultPriority` + `defaultSize`.
- [ ] Every widget receives a typed `config` and renders without `if (widgetId === ...)` branches in the dashboard code.
- [ ] Removing a module from `src/modules/index.ts` cleanly removes its widgets — no errors, no blank slot. - [ ] Removing a module from `src/modules/index.ts` cleanly removes its widgets — no errors, no blank slot.
- [ ] Widgets load in parallel (verify in network tab — no waterfall). - [ ] Widgets load in parallel (verify in network tab — no waterfall).
- [ ] Mobile layout (375px) is usable without horizontal scroll. - [ ] Mobile (375px) is usable without horizontal scroll.
- [ ] The schema column added here has a clean migration path to the `dashboards` table introduced in task 25 (a one-line copy-into-default-dashboard migration is enough).
+73
View File
@@ -0,0 +1,73 @@
# 25 — Multiple dashboards per user
## Goal
Each user can create any number of named dashboards, switch between them, and choose one as their default. No customization mechanic yet — that's task 26.
## Why
Decouples "the dashboard" from "user's home page". A user might have a *Daily*, *Garden*, and *Wedding planning* dashboard, each composing different widgets. This task introduces the entity; task 26 adds the editor.
## Depends on
- 20 (single-dashboard MVP — provides the layout shape we generalize from)
## Scope
### Schema
```sql
CREATE TABLE dashboards (
id uuid PRIMARY KEY,
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
slug text NOT NULL, -- url-safe, unique per user
is_default boolean NOT NULL DEFAULT false,
position int NOT NULL DEFAULT 0,
layout jsonb NOT NULL DEFAULT '{"version":1,"widgets":[]}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX dashboards_user_slug_uq ON dashboards (user_id, slug);
CREATE UNIQUE INDEX dashboards_user_default_uq
ON dashboards (user_id) WHERE is_default;
```
Migration also copies each user's `default_dashboard_layout` (from task 20) into a single dashboard row named "Home" with `is_default = true`, then drops the `users.default_dashboard_layout` column.
### Routes
- `/` — redirect to the user's default dashboard (`/d/<default-slug>`).
- `/d/[slug]` — render that dashboard. 404 if the slug doesn't belong to the current user. Layout rendering is unchanged from task 20 (still no edit mode).
### Server actions
- `createDashboard({ name })` — generates a slug from the name (collision-suffixed); empty layout; not default.
- `renameDashboard(id, name)` — also re-derives slug if name changes (or keeps slug stable; pick stable, document it).
- `deleteDashboard(id)` — disallowed if it's the user's only dashboard. If it was default, mark another as default before deleting.
- `setDefaultDashboard(id)` — flip flags within a transaction.
- `reorderDashboards(orderedIds)` — bulk update `position`.
All gated on `dashboards.user_id = currentUserId`.
### UI
- **Switcher in the app header** — desktop: tabs strip with the user's dashboards in `position` order, an active indicator, a "+ new" tab at the end, and a kebab on the active tab for rename / set default / delete. Mobile: dropdown with the same actions.
- **`/settings/dashboards`** — full management view: drag to reorder (use the same library task 26 will pull in, or a small `dnd-kit` setup — leave a note for task 26 to consolidate).
- New-dashboard flow: prompts for a name, creates an empty dashboard, navigates to it. Empty state explains "this dashboard has no widgets yet — task 26 will add the picker." (Or, if 26 has landed, just "Add widget".)
## Out of scope
- Drag / resize / picker / configure (task 26).
- Sharing dashboards with the household or via share-link (deferred; nullable `household_id` later).
- Per-device or per-orientation layouts.
## Acceptance criteria
- [ ] Migration moves every existing user to a single `Home` dashboard with `is_default = true`. Idempotent.
- [ ] User can create, rename, reorder, delete dashboards; cannot delete their last one; cannot have two defaults.
- [ ] `/` redirects to the user's default dashboard's `/d/<slug>`.
- [ ] Switcher renders in the header on both desktop and mobile, scoped to the current user only.
- [ ] Server actions reject any operation against a dashboard the caller doesn't own.
- [ ] No regression in task 20's layout rendering — existing widgets render identically inside the new route.
+87
View File
@@ -0,0 +1,87 @@
# 26 — Customizable layout + widget configuration
## Goal
Inside any dashboard, the user can enter edit mode, drag-and-resize widgets on a 12-col grid, add new widgets via a picker that runs the configurator step, configure existing widgets after the fact, remove widgets, and reset to defaults. Layout persists per dashboard.
## Why
Combines the drag/resize mechanic with the per-widget configurator. Because every widget is uniformly configurable (per task 04), there is no special case — adding a `lists.list` widget pointed at a specific list goes through the same path as adding a `core.activity` widget with default config.
## Depends on
- 25 (multiple dashboards — operates on `dashboards.layout`)
## Scope
### Edit mode
- "Edit dashboard" button in the dashboard header. Toggles edit mode.
- In edit mode: each widget gets a drag handle (top edge) and a resize handle (bottom-right). Each widget gets a kebab with **Configure**, **Remove**.
- Header shows **Save** (enabled when dirty) and **Cancel**. Cancel reverts unsaved changes. Save persists `dashboards.layout` via server action and exits edit mode.
- ESC = cancel.
### Grid
- `react-grid-layout` (purpose-built; the boring-but-correct choice). 12 cols on desktop. Auto-pack on add.
- Per-widget `minSize` / `maxSize` enforced from the registry.
- Mobile: edit mode is desktop-only in v1. Mobile renders the read-only single-column derivation from task 20.
### Add widget (picker)
A two-step modal. Step 1 lists every registered widget grouped by `category`, with `title` + `description`. Step 2 (always present, even when trivial) is the configurator — see below. After confirming, the widget is appended at the bottom of the grid.
### Configurator
For a given widget definition:
1. Call `widget.resolveConfigOptions(ctx)` to fetch the picker's data (e.g. user's accessible calendars).
2. Render an auto-generated form from `widget.configSchema`:
- For selection fields shaped `"all" | string[]`: a multi-select with an "All" toggle. Default value = `"all"`.
- For numbers / strings / enums: corresponding inputs.
- Use the schema's descriptions / `meta` for labels.
3. Validate on submit; reject silently-invalid configs.
Same component is reused for **Configure** on an already-placed widget. Re-runs `resolveConfigOptions`, prefilled with current config.
### Persistence shape
`dashboards.layout` schema unchanged from task 25:
```jsonc
{
"version": 1,
"widgets": [
{ "widgetId": "lists.list", "config": { "listIds": ["..."], "showCompleted": false }, "x": 0, "y": 0, "w": 6, "h": 4 }
]
}
```
Save action validates each widget's config against its registered `configSchema`, rejecting the whole save if any invalid.
### Reset to defaults
Per-dashboard "Reset to defaults" in the header kebab. Replaces `layout.widgets` with the registry's `defaultPriority`-ordered seeded set, packed by `defaultSize` — the same logic task 20 uses for null layouts.
### "New widgets available" affordance
When the registry contains widgets the user has never placed on _any_ dashboard, surface a small badge on the "+" picker button. Doesn't auto-add anything.
## Out of scope
- Mobile edit mode.
- Inline widget config (config is always picker-driven, never per-cell).
- Cross-dashboard widget cloning.
- Widget marketplaces / non-registry widgets.
- Animated transitions between layouts.
## Acceptance criteria
- [ ] Drag and resize update layout positions; Save persists; Cancel reverts.
- [ ] Adding a widget always runs the configurator step. For widgets with `configSchema = z.object({})`, the step shows "No options" and a single Add button.
- [ ] The picker shows every widget the registry provides; adding a widget defined by a freshly-installed module just works without touching `_core`.
- [ ] Configure on an existing widget re-uses the same form and persists the change on save.
- [ ] Removing a widget removes only that widget; other instances of the same `widgetId` on the same dashboard are unaffected (same widgetId can appear N times).
- [ ] Reset-to-defaults restores the registry-default layout.
- [ ] A user can have multiple `lists.list` widgets on a single dashboard pointing at different lists. Confirms the per-instance config story end-to-end.
- [ ] Save action rejects invalid configs and surfaces the validation error in-form.
+3 -1
View File
@@ -42,9 +42,11 @@ Every task file has these sections:
### Phase 3 — Dashboard & UX ### Phase 3 — Dashboard & UX
- [20 — Dashboard composition](20-dashboard.md) - [20 — Dashboard composition (single-dashboard MVP)](20-dashboard.md)
- [21 — Quick-add registry](21-quick-add.md) - [21 — Quick-add registry](21-quick-add.md)
- [22 — Activity log](22-activity-log.md) - [22 — Activity log](22-activity-log.md)
- [25 — Multiple dashboards per user](25-multiple-dashboards.md)
- [26 — Customizable layout + widget configuration](26-customizable-layout.md)
### Phase 4 — Sharing ### Phase 4 — Sharing