diff --git a/CLAUDE.md b/CLAUDE.md index fc94068..fa607fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ src/ ### Core primitives every module gets - **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. - **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/`. Generic. - **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy. @@ -74,15 +74,17 @@ src/ ## Data model (v1) - `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. -- `lists` (type enum: `shopping` | `task` | future), `list_items` +- `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. +- `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 +- `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 - `activity_log` — entity_type, entity_id, actor, action, payload jsonb - `push_subscriptions` - `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). --- diff --git a/STATUS.md b/STATUS.md index 1e0ae41..7ed53ad 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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). +## 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 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. ## Environment notes diff --git a/docs/tasks/04-module-loader.md b/docs/tasks/04-module-loader.md index 6d976cc..fdf0b05 100644 --- a/docs/tasks/04-module-loader.md +++ b/docs/tasks/04-module-loader.md @@ -6,7 +6,7 @@ Implement the module system that everything else hangs off: each module declares ## 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 @@ -21,26 +21,60 @@ export type ModuleManifest = { id: string; // "calendar", "lists", "notes" name: string; // human-readable nav?: { href: string; label: string; icon?: string }; - entities: EntityTypeRegistration[]; // see below + entities: EntityTypeRegistration[]; dashboardWidgets?: DashboardWidget[]; quickAdds?: QuickAddAction[]; }; 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 }; - share?: ShareCapabilities; // null = not shareable - reminder?: ReminderCapabilities; // null = not remindable - search?: SearchAdapter; // null = not searchable - resolveUrl: (id: string) => string; // canonical app URL - loadForShare?: (id: string) => Promise; // payload for /s/ + share?: ShareCapabilities; // omit = not shareable + reminder?: ReminderCapabilities; // omit = not remindable + search?: SearchAdapter; // omit = not searchable + resolveUrl: (id: string) => string; + loadForShare?: (id: string) => Promise; }; ``` +### 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; + // 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`) -- `registerModule(manifest)` and `getRegistry()` returning frozen views. +- `registerModule(manifest)`, `getRegistry()` returning frozen views. - `getEntityType(type)` lookup. +- `getWidget(id)` lookup. - Import-time side-effect free: modules are listed and registered explicitly in `src/modules/index.ts`. ### Loader (`src/modules/index.ts`) @@ -51,11 +85,12 @@ export type EntityTypeRegistration = { ### Wiring - 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 - 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. ## Acceptance criteria @@ -63,10 +98,13 @@ export type EntityTypeRegistration = { - [ ] `src/modules/index.ts` registers three stub modules. - [ ] Nav renders entries from the registry, not from a hardcoded list. - [ ] `/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. +- [ ] No widget is special-cased in `_core` — the registry treats every widget uniformly. ## 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. - Resist the urge to make this a fancy DI container. A plain map is enough. diff --git a/docs/tasks/10-calendar-module.md b/docs/tasks/10-calendar-module.md index 10f33bb..5895e17 100644 --- a/docs/tasks/10-calendar-module.md +++ b/docs/tasks/10-calendar-module.md @@ -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. diff --git a/docs/tasks/11-lists-module.md b/docs/tasks/11-lists-module.md index ee933f6..849aa86 100644 --- a/docs/tasks/11-lists-module.md +++ b/docs/tasks/11-lists-module.md @@ -30,9 +30,10 @@ Seed: one default list of each type per household on first access (idempotent). ### Manifest -- Entity type `lists.list` (shareable read+write via share token), `lists.item` (not directly shareable; inherits via list). -- Quick-adds: "Add to shopping", "Add to tasks" (each adds a single item to the default list of that type). -- Dashboard widget: top 5 unchecked shopping items + open tasks assigned to me. +- 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" (single item to the household's default list of that type), "New list". +- 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 diff --git a/docs/tasks/12-notes-module.md b/docs/tasks/12-notes-module.md index 29daa25..e87cb03 100644 --- a/docs/tasks/12-notes-module.md +++ b/docs/tasks/12-notes-module.md @@ -14,8 +14,9 @@ Lightweight shared notes with optional reminders. - `/notes` index: pinned first, then by updated_at desc. - `/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). -- Manifest: shareable, remindable, searchable (title + body). -- Dashboard widget: pinned notes. +- Manifest: + - 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 diff --git a/docs/tasks/20-dashboard.md b/docs/tasks/20-dashboard.md index 7186c00..eca11ba 100644 --- a/docs/tasks/20-dashboard.md +++ b/docs/tasks/20-dashboard.md @@ -1,33 +1,82 @@ -# 20 — Dashboard composition +# 20 — Dashboard composition (single-dashboard MVP) ## 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 -- 10, 11, 12 +- 10 (calendar), 11 (lists), 12 (notes) ## Scope -- `DashboardWidget` type already in `_core` (from task 04). Each widget exports `{ id, priority, render }`. -- `/` reads `getRegistry().dashboardWidgets`, sorts by priority desc, renders them in a responsive grid (1 col mobile, 2-3 cols desktop). -- Widgets implemented: - - **calendar.upcoming** — next 3 days of events - - **lists.shopping** — top 5 unchecked items in default shopping list - - **lists.tasks** — my open tasks (assigned to me, due soon first) - - **notes.pinned** — pinned notes - - **core.activity** — recent activity feed (depends on task 22; until then render a stub) -- Skeleton loaders while widgets load (each widget is a server component with `loading.tsx`-equivalent Suspense boundary). +### Schema (interim, generalized in task 25) + +Add a `default_dashboard_layout` jsonb column on `users`. Shape: + +```jsonc +{ + "version": 1, + "widgets": [ + { + "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). +- _No_ edit-mode toggle yet — that lands in task 26. ## Out of scope -- User-configurable widget order or hide/show (defer; pin a sane default order). -- Drag-rearrange. +- Multiple dashboards (task 25). +- Drag, resize, picker, configure UI (task 26). +- User-configurable widget order. Defaults are taken from `defaultPriority`. ## 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. - [ ] 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). diff --git a/docs/tasks/25-multiple-dashboards.md b/docs/tasks/25-multiple-dashboards.md new file mode 100644 index 0000000..91c971e --- /dev/null +++ b/docs/tasks/25-multiple-dashboards.md @@ -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/`). +- `/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/`. +- [ ] 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. diff --git a/docs/tasks/26-customizable-layout.md b/docs/tasks/26-customizable-layout.md new file mode 100644 index 0000000..9a2ee1d --- /dev/null +++ b/docs/tasks/26-customizable-layout.md @@ -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. diff --git a/docs/tasks/README.md b/docs/tasks/README.md index eeda1bb..6dd6fd7 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -42,9 +42,11 @@ Every task file has these sections: ### 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) - [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