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:
@@ -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/<token>`. 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<unknown>; // payload for /s/<token>
|
||||
share?: ShareCapabilities; // omit = not shareable
|
||||
reminder?: ReminderCapabilities; // omit = not remindable
|
||||
search?: SearchAdapter; // omit = not searchable
|
||||
resolveUrl: (id: string) => string;
|
||||
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`)
|
||||
|
||||
- `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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+64
-15
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user