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.
4.8 KiB
4.8 KiB
04 — Module loader & registry
Goal
Implement the module system that everything else hangs off: each module declares a manifest, the loader composes them at startup, and core services iterate the registry instead of hardcoding entity types.
Why
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
- 03 (DB)
Scope
Module manifest type (src/modules/_core/module.ts)
export type ModuleManifest = {
id: string; // "calendar", "lists", "notes"
name: string; // human-readable
nav?: { href: string; label: string; icon?: string };
entities: EntityTypeRegistration[];
dashboardWidgets?: DashboardWidget[];
quickAdds?: QuickAddAction[];
};
export type EntityTypeRegistration = {
type: string; // "calendar.event", "calendar.calendar", "lists.list", "notes.note"
label: { singular: string; plural: string };
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.
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),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)
- Imports each module's manifest and calls
registerModule. Order is deterministic. - Stub modules for now:
calendar,lists,noteseach export an empty-but-valid manifest so the loader has something to register.
Wiring
- Root layout reads the registry to render nav.
- A throwaway
/debug/registrypage (dev-only) dumps the loaded registry as JSON, including every widget's id, config schema (rendered viazod-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
src/modules/index.tsregisters three stub modules.- Nav renders entries from the registry, not from a hardcoded list.
/debug/registryshows all three module manifests in dev.- Adding a fourth stub module requires only creating its folder + adding one line to
src/modules/index.ts. - All types exported from
_coreso other modules can import without circulars. - No widget is special-cased in
_core— the registry treats every widget uniformly.
Notes
- Use
zodforconfigSchema. Pin a single major version across the repo so types stay compatible. resolveConfigOptionsis 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.