Files
famapp/docs/tasks/04-module-loader.md
T
ginnoir 09d6adbf18 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.
2026-05-06 01:14:49 -05:00

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, notes each 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/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

  • 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 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.