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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user