From e237fe8449056853d118f0f837200006322c0639 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Wed, 6 May 2026 01:52:47 -0500 Subject: [PATCH] Add module loader & registry (task 04) Implements the extensibility foundation: ModuleManifest/DashboardWidget/EntityTypeRegistration types in _core/module.ts, a plain-Map registry singleton with registerModule/getRegistry/getEntityType/getWidget, and stub manifests for calendar/lists/notes. Root layout imports src/modules/index.ts for side-effect registration; AppNav reads the registry to render nav links. /debug/registry dumps the full registry JSON in dev using zod v4 + z.toJSONSchema(). Adding a fourth module requires one folder + one line in index.ts. Co-Authored-By: Claude Sonnet 4.6 --- STATUS.md | 3 +- package.json | 4 +- pnpm-lock.yaml | 10 +++++ src/app/debug/registry/page.tsx | 51 ++++++++++++++++++++++++ src/app/layout.tsx | 9 ++++- src/components/app-nav.tsx | 24 +++++++++++ src/modules/_core/index.ts | 12 ++++++ src/modules/_core/module.ts | 68 ++++++++++++++++++++++++++++++++ src/modules/_core/registry.ts | 31 +++++++++++++++ src/modules/calendar/manifest.ts | 23 +++++++++++ src/modules/index.ts | 8 ++++ src/modules/lists/manifest.ts | 23 +++++++++++ src/modules/notes/manifest.ts | 18 +++++++++ 13 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 src/app/debug/registry/page.tsx create mode 100644 src/components/app-nav.tsx create mode 100644 src/modules/_core/index.ts create mode 100644 src/modules/_core/module.ts create mode 100644 src/modules/_core/registry.ts create mode 100644 src/modules/calendar/manifest.ts create mode 100644 src/modules/index.ts create mode 100644 src/modules/lists/manifest.ts create mode 100644 src/modules/notes/manifest.ts diff --git a/STATUS.md b/STATUS.md index 8a2b0fd..c825f33 100644 --- a/STATUS.md +++ b/STATUS.md @@ -7,10 +7,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is - **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green. - **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility. - **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes. +- **04 — Module loader & registry**. `src/modules/_core/module.ts` types (`ModuleManifest`, `EntityTypeRegistration`, `DashboardWidget`, etc.), `registry.ts` singleton with `registerModule`/`getRegistry`/`getEntityType`/`getWidget`, barrel `_core/index.ts`. Stub manifests for `calendar`, `lists`, `notes`. `src/modules/index.ts` loader. Root layout imports loader; `AppNav` reads registry for nav links. `/debug/registry` dumps full registry JSON (dev only). Uses zod v4 + built-in `z.toJSONSchema()`. `tsc --noEmit`, `pnpm build`, `pnpm lint` all clean. ## Next up -- **04 — Module loader** ([brief](docs/tasks/04-module-loader.md)). Phase 1 is sequential through 08. +- **05 — Compose + Caddy** ([brief](docs/tasks/05-compose-caddy.md)). ## Phase 1 remaining diff --git a/package.json b/package.json index 5dd3c94..cd9156c 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "react-dom": "^19.2.5", "shadcn": "^4.7.0", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3", + "zod-to-json-schema": "^3.25.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3baa20..d347444 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + zod-to-json-schema: + specifier: ^3.25.2 + version: 3.25.2(zod@4.4.3) devDependencies: '@eslint/eslintrc': specifier: ^3.3.5 @@ -7142,6 +7148,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/src/app/debug/registry/page.tsx b/src/app/debug/registry/page.tsx new file mode 100644 index 0000000..98cc0cf --- /dev/null +++ b/src/app/debug/registry/page.tsx @@ -0,0 +1,51 @@ +import { notFound } from "next/navigation"; +import { z } from "zod"; +import { getRegistry } from "@/modules/_core/registry"; +import "@/modules"; + +export default function RegistryDebugPage() { + if (process.env.NODE_ENV !== "development") { + notFound(); + } + + const { modules } = getRegistry(); + + const output = { + modules: modules.map((m) => ({ + id: m.id, + name: m.name, + nav: m.nav ?? null, + entities: m.entities.map((e) => ({ + type: e.type, + label: e.label, + hasShare: !!e.share, + hasReminder: !!e.reminder, + hasSearch: !!e.search, + })), + dashboardWidgets: (m.dashboardWidgets ?? []).map((w) => ({ + id: w.id, + title: w.title, + description: w.description, + category: w.category ?? null, + defaultSize: w.defaultSize, + defaultPriority: w.defaultPriority, + configSchema: z.toJSONSchema(w.configSchema), + defaultConfig: w.defaultConfig, + })), + quickAdds: (m.quickAdds ?? []).map((q) => ({ + id: q.id, + label: q.label, + icon: q.icon ?? null, + })), + })), + }; + + return ( +
+

Module Registry (dev only)

+
+        {JSON.stringify(output, null, 2)}
+      
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 05472e2..5273a42 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,8 +2,10 @@ import type { Metadata } from "next"; import "./globals.css"; import { Geist } from "next/font/google"; import { cn } from "@/lib/utils"; +import { AppNav } from "@/components/app-nav"; +import "@/modules"; // registers all module manifests -const geist = Geist({subsets:['latin'],variable:'--font-sans'}); +const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); export const metadata: Metadata = { title: "famapp", @@ -17,7 +19,10 @@ export default function RootLayout({ }) { return ( - {children} + + +
{children}
+ ); } diff --git a/src/components/app-nav.tsx b/src/components/app-nav.tsx new file mode 100644 index 0000000..df2fbae --- /dev/null +++ b/src/components/app-nav.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import { getRegistry } from "@/modules/_core/registry"; + +export function AppNav() { + const { modules } = getRegistry(); + const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : [])); + + return ( + + ); +} diff --git a/src/modules/_core/index.ts b/src/modules/_core/index.ts new file mode 100644 index 0000000..fd530eb --- /dev/null +++ b/src/modules/_core/index.ts @@ -0,0 +1,12 @@ +export type { + ModuleManifest, + EntityTypeRegistration, + DashboardWidget, + QuickAddAction, + WidgetContext, + ShareCapabilities, + ReminderCapabilities, + SearchAdapter, + SearchResult, +} from "./module"; +export { registerModule, getRegistry, getEntityType, getWidget } from "./registry"; diff --git a/src/modules/_core/module.ts b/src/modules/_core/module.ts new file mode 100644 index 0000000..e35f3b2 --- /dev/null +++ b/src/modules/_core/module.ts @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import type { ZodType } from "zod"; + +export type ShareCapabilities = { + canShare: boolean; + defaultCapabilities?: string[]; +}; + +export type ReminderCapabilities = { + canRemind: boolean; +}; + +export type SearchResult = { + id: string; + title: string; + url: string; + excerpt?: string; +}; + +export type SearchAdapter = { + search: (query: string, householdId: string) => Promise; +}; + +export type WidgetContext = { + userId: string; + householdId: string; +}; + +export type DashboardWidget = { + id: string; + title: string; + description: string; + category?: string; + defaultSize: { w: number; h: number }; + minSize?: { w: number; h: number }; + maxSize?: { w: number; h: number }; + defaultPriority: number; + configSchema: ZodType; + defaultConfig: unknown; + resolveConfigOptions?: (ctx: WidgetContext) => Promise; + render: (props: { config: unknown; ctx: WidgetContext }) => ReactNode; +}; + +export type QuickAddAction = { + id: string; + label: string; + icon?: string; + action: (ctx: WidgetContext) => void | Promise; +}; + +export type EntityTypeRegistration = { + type: string; + label: { singular: string; plural: string }; + share?: ShareCapabilities; + reminder?: ReminderCapabilities; + search?: SearchAdapter; + resolveUrl: (id: string) => string; + loadForShare?: (id: string) => Promise; +}; + +export type ModuleManifest = { + id: string; + name: string; + nav?: { href: string; label: string; icon?: string }; + entities: EntityTypeRegistration[]; + dashboardWidgets?: DashboardWidget[]; + quickAdds?: QuickAddAction[]; +}; diff --git a/src/modules/_core/registry.ts b/src/modules/_core/registry.ts new file mode 100644 index 0000000..7cb98a3 --- /dev/null +++ b/src/modules/_core/registry.ts @@ -0,0 +1,31 @@ +import type { ModuleManifest, EntityTypeRegistration, DashboardWidget } from "./module"; + +const modules = new Map(); +const entityTypes = new Map(); +const widgets = new Map(); + +export function registerModule(manifest: ModuleManifest): void { + modules.set(manifest.id, manifest); + for (const entity of manifest.entities) { + entityTypes.set(entity.type, entity); + } + for (const widget of manifest.dashboardWidgets ?? []) { + widgets.set(widget.id, widget); + } +} + +export function getRegistry() { + return Object.freeze({ + modules: Object.freeze([...modules.values()]), + entityTypes: Object.freeze([...entityTypes.values()]), + widgets: Object.freeze([...widgets.values()]), + }); +} + +export function getEntityType(type: string): EntityTypeRegistration | undefined { + return entityTypes.get(type); +} + +export function getWidget(id: string): DashboardWidget | undefined { + return widgets.get(id); +} diff --git a/src/modules/calendar/manifest.ts b/src/modules/calendar/manifest.ts new file mode 100644 index 0000000..abf7310 --- /dev/null +++ b/src/modules/calendar/manifest.ts @@ -0,0 +1,23 @@ +import type { ModuleManifest } from "../_core/module"; + +const manifest: ModuleManifest = { + id: "calendar", + name: "Calendar", + nav: { href: "/calendar", label: "Calendar", icon: "calendar" }, + entities: [ + { + type: "calendar.calendar", + label: { singular: "Calendar", plural: "Calendars" }, + resolveUrl: (id) => `/calendar?id=${id}`, + }, + { + type: "calendar.event", + label: { singular: "Event", plural: "Events" }, + resolveUrl: (id) => `/calendar/events/${id}`, + }, + ], + dashboardWidgets: [], + quickAdds: [], +}; + +export default manifest; diff --git a/src/modules/index.ts b/src/modules/index.ts new file mode 100644 index 0000000..4ea8d1b --- /dev/null +++ b/src/modules/index.ts @@ -0,0 +1,8 @@ +import { registerModule } from "./_core/registry"; +import calendarManifest from "./calendar/manifest"; +import listsManifest from "./lists/manifest"; +import notesManifest from "./notes/manifest"; + +registerModule(calendarManifest); +registerModule(listsManifest); +registerModule(notesManifest); diff --git a/src/modules/lists/manifest.ts b/src/modules/lists/manifest.ts new file mode 100644 index 0000000..0c2b2ab --- /dev/null +++ b/src/modules/lists/manifest.ts @@ -0,0 +1,23 @@ +import type { ModuleManifest } from "../_core/module"; + +const manifest: ModuleManifest = { + id: "lists", + name: "Lists", + nav: { href: "/lists", label: "Lists", icon: "list" }, + entities: [ + { + type: "lists.list", + label: { singular: "List", plural: "Lists" }, + resolveUrl: (id) => `/lists/${id}`, + }, + { + type: "lists.item", + label: { singular: "List item", plural: "List items" }, + resolveUrl: (id) => `/lists/items/${id}`, + }, + ], + dashboardWidgets: [], + quickAdds: [], +}; + +export default manifest; diff --git a/src/modules/notes/manifest.ts b/src/modules/notes/manifest.ts new file mode 100644 index 0000000..9730d53 --- /dev/null +++ b/src/modules/notes/manifest.ts @@ -0,0 +1,18 @@ +import type { ModuleManifest } from "../_core/module"; + +const manifest: ModuleManifest = { + id: "notes", + name: "Notes", + nav: { href: "/notes", label: "Notes", icon: "file-text" }, + entities: [ + { + type: "notes.note", + label: { singular: "Note", plural: "Notes" }, + resolveUrl: (id) => `/notes/${id}`, + }, + ], + dashboardWidgets: [], + quickAdds: [], +}; + +export default manifest;