From b4dde92757205dc69bd8fc20b094786c50bf5c5a Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 6 May 2026 13:24:56 -0500 Subject: [PATCH] Implement dashboard composition (task 20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add default_dashboard_layout jsonb to users + migration 0007 - Create _core/manifest.tsx with core.activity placeholder widget - Register core manifest alongside calendar/lists/notes - Update all three module manifests with real async server-component widget renders (upcoming events, month view, list items, notes) - Add src/lib/dashboard.ts: computeDefaultLayout greedy packer + parseDashboardLayout Zod validator - Build src/app/page.tsx: 12-col CSS Grid, static smColSpan lookup, per-widget Suspense for parallel loading, generic widget.render() dispatch — no widgetId branches - Add tests/e2e/dashboard.spec.ts; all 4 E2E specs pass Co-Authored-By: Claude Sonnet 4.6 --- STATUS.md | 1 + drizzle/0007_uneven_living_lightning.sql | 1 + src/app/page.tsx | 90 ++++++++++++++++++-- src/lib/dashboard.ts | 67 +++++++++++++++ src/modules/_core/manifest.tsx | 31 +++++++ src/modules/_core/schema.ts | 2 + src/modules/calendar/manifest.tsx | 101 +++++++++++++++++++++-- src/modules/index.ts | 2 + src/modules/lists/manifest.tsx | 64 +++++++++----- src/modules/notes/manifest.tsx | 41 ++++++--- tests/e2e/dashboard.spec.ts | 26 ++++++ 11 files changed, 380 insertions(+), 46 deletions(-) create mode 100644 drizzle/0007_uneven_living_lightning.sql create mode 100644 src/lib/dashboard.ts create mode 100644 src/modules/_core/manifest.tsx create mode 100644 tests/e2e/dashboard.spec.ts diff --git a/STATUS.md b/STATUS.md index d04cd87..f14fd9a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -19,6 +19,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass. - **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass. - **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass. +- **20 — Dashboard composition (single-dashboard MVP)**. Added `default_dashboard_layout` jsonb column to `users` + migration `0007_uneven_living_lightning.sql`. Created `src/modules/_core/manifest.tsx` (`core.activity` placeholder widget) and registered it. Updated all three module manifests (calendar, lists, notes) with real async server component widget renders (data-fetching, empty states). Created `src/lib/dashboard.ts` (layout parsing + `computeDefaultLayout` greedy packer). Built `src/app/page.tsx` — 12-col CSS Grid, static `smColSpan` lookup for Tailwind class safety, per-widget `` for parallel loading, graceful skip for unknown widget IDs. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass. ## Next up diff --git a/drizzle/0007_uneven_living_lightning.sql b/drizzle/0007_uneven_living_lightning.sql new file mode 100644 index 0000000..2159b82 --- /dev/null +++ b/drizzle/0007_uneven_living_lightning.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "default_dashboard_layout" jsonb; \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index adbaeba..b1c48d4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,8 +1,88 @@ -export default function Page() { +import { Suspense } from "react"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { computeDefaultLayout, parseDashboardLayout } from "@/lib/dashboard"; +import { getCurrentSession } from "@/lib/session"; +import { getWidget } from "@/modules/_core"; +import { users } from "@/modules/_core/schema"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +// Static lookup ensures Tailwind sees all sm:col-span-* classes at build time. +const smColSpan: Record = { + 1: "sm:col-span-1", + 2: "sm:col-span-2", + 3: "sm:col-span-3", + 4: "sm:col-span-4", + 5: "sm:col-span-5", + 6: "sm:col-span-6", + 7: "sm:col-span-7", + 8: "sm:col-span-8", + 9: "sm:col-span-9", + 10: "sm:col-span-10", + 11: "sm:col-span-11", + 12: "sm:col-span-12", +}; + +export default async function DashboardPage() { + const { user, household } = await getCurrentSession(); + + const [row] = await db + .select({ layout: users.defaultDashboardLayout }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + const layout = parseDashboardLayout(row?.layout) ?? computeDefaultLayout(); + + const ctx = { userId: user.id, householdId: household.id }; + + // Sort by y then x so document order matches visual order on mobile (single col). + const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x); + return ( -
-

famapp

-

Dashboard coming soon

-
+
+
+

Dashboard

+ {/* Quick-add FAB — wired up in task 21 */} + +
+ +
+ {placements.map((placement, i) => { + const widget = getWidget(placement.widgetId); + if (!widget) return null; + + const colClass = smColSpan[placement.w] ?? "sm:col-span-12"; + + return ( +
+ + + {widget.title} + + + +
+
+
+
+ } + > + {widget.render({ config: placement.config, ctx })} + + + +
+ ); + })} +
+
); } diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts new file mode 100644 index 0000000..774bc3a --- /dev/null +++ b/src/lib/dashboard.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { getRegistry } from "@/modules/_core"; + +export type WidgetPlacement = { + widgetId: string; + config: unknown; + x: number; + y: number; + w: number; + h: number; +}; + +export type DashboardLayout = { + version: 1; + widgets: WidgetPlacement[]; +}; + +const placementSchema = z.object({ + widgetId: z.string(), + config: z.unknown(), + x: z.number().int().min(0), + y: z.number().int().min(0), + w: z.number().int().min(1).max(12), + h: z.number().int().min(1), +}); + +const layoutSchema = z.object({ + version: z.literal(1), + widgets: z.array(placementSchema), +}); + +export function parseDashboardLayout(raw: unknown): DashboardLayout | null { + const result = layoutSchema.safeParse(raw); + if (!result.success) return null; + return result.data; +} + +export function computeDefaultLayout(): DashboardLayout { + const { widgets } = getRegistry(); + const sorted = [...widgets].sort((a, b) => a.defaultPriority - b.defaultPriority); + + const placements: WidgetPlacement[] = []; + let curX = 0; + let curY = 0; + let rowH = 0; + + for (const widget of sorted) { + const { w, h } = widget.defaultSize; + if (curX + w > 12) { + curY += rowH; + curX = 0; + rowH = 0; + } + placements.push({ + widgetId: widget.id, + config: widget.defaultConfig, + x: curX, + y: curY, + w, + h, + }); + curX += w; + rowH = Math.max(rowH, h); + } + + return { version: 1, widgets: placements }; +} diff --git a/src/modules/_core/manifest.tsx b/src/modules/_core/manifest.tsx new file mode 100644 index 0000000..3489044 --- /dev/null +++ b/src/modules/_core/manifest.tsx @@ -0,0 +1,31 @@ +import type { ModuleManifest } from "./module"; +import { z } from "zod"; + +const coreManifest: ModuleManifest = { + id: "_core", + name: "Core", + entities: [], + dashboardWidgets: [ + { + id: "core.activity", + title: "Recent activity", + description: "Latest changes across your household.", + category: "Core", + defaultSize: { w: 4, h: 3 }, + minSize: { w: 3, h: 2 }, + defaultPriority: 50, + configSchema: z.object({ + limit: z.number().int().min(1).max(50).optional(), + }), + defaultConfig: { limit: 10 }, + resolveConfigOptions: async () => undefined, + render: () => ( +
+ Activity log coming in task 22 +
+ ), + }, + ], +}; + +export default coreManifest; diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index 6c9f477..161dcc0 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -2,6 +2,7 @@ import type { AdapterAccountType } from "@auth/core/adapters"; import { index, integer, + jsonb, pgEnum, pgTable, primaryKey, @@ -23,6 +24,7 @@ export const users = pgTable("users", { image: text("image"), theme: text("theme").notNull().default("default"), themeMode: text("theme_mode").notNull().default("system"), + defaultDashboardLayout: jsonb("default_dashboard_layout"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/src/modules/calendar/manifest.tsx b/src/modules/calendar/manifest.tsx index 87f1daf..83f39f5 100644 --- a/src/modules/calendar/manifest.tsx +++ b/src/modules/calendar/manifest.tsx @@ -1,9 +1,95 @@ -import type { ModuleManifest } from "../_core/module"; +import type { ModuleManifest, WidgetContext } from "../_core/module"; import { z } from "zod"; -import { listCalendars, searchCalendars, searchEvents } from "./server/queries"; +import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries"; const calendarIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]); +const upcomingConfigSchema = z.object({ + calendarIds: calendarIdsSchema, + days: z.number().int().min(1).max(30), +}); + +const monthConfigSchema = z.object({ calendarIds: calendarIdsSchema }); + +async function UpcomingEventsWidget({ + config, +}: { + config: unknown; + ctx: WidgetContext; +}) { + const parsed = upcomingConfigSchema.parse(config); + const now = new Date(); + const end = new Date(now.getTime() + parsed.days * 24 * 60 * 60 * 1000); + const events = await listEvents({ from: now, to: end, calendarIds: parsed.calendarIds }); + + if (events.length === 0) { + return ( +

+ No events in the next {parsed.days} day{parsed.days !== 1 ? "s" : ""} +

+ ); + } + + return ( +
    + {events.slice(0, 8).map((event) => { + const start = new Date(event.startAt); + const label = event.allDay + ? start.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + : start.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + return ( +
  • + {label} + {event.title} +
  • + ); + })} +
+ ); +} + +async function MonthWidget({ config }: { config: unknown; ctx: WidgetContext }) { + const parsed = monthConfigSchema.parse(config); + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); + const events = await listEvents({ from: monthStart, to: monthEnd, calendarIds: parsed.calendarIds }); + + const monthName = now.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + + return ( +
+

{monthName}

+ {events.length === 0 ? ( +

No events this month

+ ) : ( +
    + {events.slice(0, 10).map((event) => { + const eventStart = new Date(event.startAt); + const day = eventStart.getDate(); + return ( +
  • + + {day} + + {event.title} +
  • + ); + })} + {events.length > 10 && ( +
  • +{events.length - 10} more
  • + )} +
+ )} +
+ ); +} + const manifest: ModuleManifest = { id: "calendar", name: "Calendar", @@ -34,10 +120,7 @@ const manifest: ModuleManifest = { defaultSize: { w: 4, h: 3 }, minSize: { w: 3, h: 2 }, defaultPriority: 10, - configSchema: z.object({ - calendarIds: calendarIdsSchema, - days: z.number().int().min(1).max(30), - }), + configSchema: upcomingConfigSchema, defaultConfig: { calendarIds: "all", days: 3 }, resolveConfigOptions: async () => ({ calendars: (await listCalendars()).map((calendar) => ({ @@ -46,7 +129,7 @@ const manifest: ModuleManifest = { visibility: calendar.visibility, })), }), - render: () =>
Upcoming events
, + render: (props) => , }, { id: "calendar.month", @@ -56,7 +139,7 @@ const manifest: ModuleManifest = { defaultSize: { w: 6, h: 5 }, minSize: { w: 4, h: 4 }, defaultPriority: 20, - configSchema: z.object({ calendarIds: calendarIdsSchema }), + configSchema: monthConfigSchema, defaultConfig: { calendarIds: "all" }, resolveConfigOptions: async () => ({ calendars: (await listCalendars()).map((calendar) => ({ @@ -65,7 +148,7 @@ const manifest: ModuleManifest = { visibility: calendar.visibility, })), }), - render: () =>
Month calendar
, + render: (props) => , }, ], quickAdds: [ diff --git a/src/modules/index.ts b/src/modules/index.ts index 4ea8d1b..e80c2a0 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,8 +1,10 @@ import { registerModule } from "./_core/registry"; +import coreManifest from "./_core/manifest"; import calendarManifest from "./calendar/manifest"; import listsManifest from "./lists/manifest"; import notesManifest from "./notes/manifest"; +registerModule(coreManifest); registerModule(calendarManifest); registerModule(listsManifest); registerModule(notesManifest); diff --git a/src/modules/lists/manifest.tsx b/src/modules/lists/manifest.tsx index bbc4ec6..79e4f60 100644 --- a/src/modules/lists/manifest.tsx +++ b/src/modules/lists/manifest.tsx @@ -1,10 +1,49 @@ -import type { ModuleManifest } from "../_core/module"; +import type { ModuleManifest, WidgetContext } from "../_core/module"; import { z } from "zod"; import { addItemToDefaultList } from "./server/actions"; -import { listLists, searchItems, searchLists } from "./server/queries"; +import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries"; const listIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]); +const listWidgetConfigSchema = z.object({ + listIds: listIdsSchema, + showCompleted: z.boolean(), + limit: z.number().int().min(1).max(50).optional(), +}); + +async function ListWidget({ config }: { config: unknown; ctx: WidgetContext }) { + const parsed = listWidgetConfigSchema.parse(config); + const items = await listWidgetItems({ + listIds: parsed.listIds, + showCompleted: parsed.showCompleted, + limit: parsed.limit ?? 10, + }); + + if (items.length === 0) { + return ( +

+ {parsed.showCompleted ? "No items" : "No open items"} +

+ ); + } + + return ( +
    + {items.map((item) => ( +
  • + + + {item.text} + + {item.listName} +
  • + ))} +
+ ); +} + const manifest: ModuleManifest = { id: "lists", name: "Lists", @@ -34,11 +73,7 @@ const manifest: ModuleManifest = { defaultSize: { w: 4, h: 3 }, minSize: { w: 3, h: 2 }, defaultPriority: 30, - configSchema: z.object({ - listIds: listIdsSchema, - showCompleted: z.boolean(), - limit: z.number().int().min(1).max(50).optional(), - }), + configSchema: listWidgetConfigSchema, defaultConfig: { listIds: "all", showCompleted: false }, resolveConfigOptions: async () => ({ lists: (await listLists()).map((list) => ({ @@ -47,20 +82,7 @@ const manifest: ModuleManifest = { name: list.name, })), }), - render: ({ config }) => { - const parsed = z - .object({ - listIds: listIdsSchema, - showCompleted: z.boolean(), - limit: z.number().int().min(1).max(50).optional(), - }) - .parse(config); - return ( -
- {parsed.showCompleted ? "List items" : "Open list items"} -
- ); - }, + render: (props) => , }, ], quickAdds: [ diff --git a/src/modules/notes/manifest.tsx b/src/modules/notes/manifest.tsx index 7cc6cd0..d8769e8 100644 --- a/src/modules/notes/manifest.tsx +++ b/src/modules/notes/manifest.tsx @@ -1,12 +1,38 @@ -import type { ModuleManifest } from "../_core/module"; +import type { ModuleManifest, WidgetContext } from "../_core/module"; import { z } from "zod"; -import { searchNotes } from "./server/queries"; +import { listWidgetNotes, searchNotes } from "./server/queries"; const notesWidgetConfigSchema = z.object({ filter: z.enum(["pinned", "all"]), limit: z.number().int().min(1).max(50).optional(), }); +async function NotesWidget({ config }: { config: unknown; ctx: WidgetContext }) { + const parsed = notesWidgetConfigSchema.parse(config); + const notes = await listWidgetNotes({ filter: parsed.filter, limit: parsed.limit ?? 5 }); + + if (notes.length === 0) { + return ( +

+ {parsed.filter === "pinned" ? "No pinned notes" : "No notes"} +

+ ); + } + + return ( +
    + {notes.map((note) => ( +
  • +

    {note.title}

    + {note.body && ( +

    {note.body}

    + )} +
  • + ))} +
+ ); +} + const manifest: ModuleManifest = { id: "notes", name: "Notes", @@ -31,16 +57,9 @@ const manifest: ModuleManifest = { minSize: { w: 3, h: 2 }, defaultPriority: 40, configSchema: notesWidgetConfigSchema, - defaultConfig: { filter: "pinned", limit: 10 }, + defaultConfig: { filter: "pinned", limit: 5 }, resolveConfigOptions: async () => undefined, - render: ({ config }) => { - const parsed = notesWidgetConfigSchema.parse(config); - return ( -
- {parsed.filter === "pinned" ? "Pinned notes" : "Notes"} -
- ); - }, + render: (props) => , }, ], quickAdds: [ diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts new file mode 100644 index 0000000..b4551b3 --- /dev/null +++ b/tests/e2e/dashboard.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test"; + +test("dashboard happy path", async ({ page }) => { + await page.goto("/"); + + // Page title is present + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + + // Quick-add FAB is present + await expect(page.getByRole("button", { name: "Quick add" })).toBeVisible(); + + // Widget card titles from registered manifests should appear (exact match inside main) + const main = page.getByRole("main"); + await expect(main.getByText("Upcoming events", { exact: true })).toBeVisible(); + await expect(main.getByText("List items", { exact: true })).toBeVisible(); + await expect(main.getByText("Notes", { exact: true }).first()).toBeVisible(); + await expect(main.getByText("Recent activity", { exact: true })).toBeVisible(); + + // No horizontal scroll on mobile viewport + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); + const viewportWidth = await page.evaluate(() => window.innerWidth); + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 2); // 2px tolerance for sub-pixel rendering +});