managarten/apps/mana/apps/web/src/lib/data/module-context.ts
Till JS f9b6720d15 feat: E2E smoke test, lazy widget loading, typed module context
Three medium-sized improvements:

1. E2E Smoke Test (e2e/smoke.spec.ts)
   Two Playwright tests that exercise the critical happy path:
   - "boot → dashboard → navigate → verify": opens /, /todo,
     /notes, /habits, /calc in sequence, verifies each renders
     content, checks for console errors.
   - "module routing: all core routes respond": iterates 11 core
     routes (/todo, /calendar, /contacts, /notes, /habits, /calc,
     /chat, /body, /dreams, /finance, /moodlit) and asserts no
     SvelteKit error page or crash. Runs in guest mode using the
     existing dismissWelcomeModal helper.

2. Lazy Widget Loading (WidgetContainer.svelte)
   Dashboard widgets are now lazy-mounted via IntersectionObserver.
   Offscreen widgets render a small pulse placeholder until they
   scroll into the viewport (with 200px rootMargin for pre-loading).
   Once visible, the widget stays mounted permanently. This defers
   liveQuery subscriptions for the ~13 dashboard widgets so only
   the ~3-4 above-the-fold widgets fire IndexedDB reads on initial
   mount — the rest activate as the user scrolls.

3. Typed Module Context (lib/data/module-context.ts)
   `createModuleContext<T>(key)` returns a `{ provide, consume }`
   pair that wraps Svelte's setContext/getContext with compile-time
   type safety. Replaces the manual
   `getContext<{readonly value: T[]}>('key')` pattern that was
   duplicated across every layout/page boundary with a fragile
   inline type annotation. Example usage added for the body module
   (body/context.ts) — other modules can adopt incrementally.

Turborepo type-check task was already in place (turbo.json + root
package.json). No changes needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:17:57 +02:00

74 lines
2.3 KiB
TypeScript

/**
* Typed Module Context — replaces manual getContext/setContext with
* type-safe wrappers that guarantee the shape at compile time.
*
* Problem:
* Layouts do `setContext('todos', useAllTodos())` and pages read
* `const ctx: { readonly value: Todo[] } = getContext('todos')`.
* The type annotation is manual, duplicated, and drifts silently
* when the query return shape changes.
*
* Solution:
* `createModuleContext<T>('todos')` returns a pair of typed
* functions: `provide(value)` for layouts and `consume()` for
* pages. Both enforce the same T at compile time.
*
* Usage:
*
* ```ts
* // In the module's queries.ts or a dedicated context.ts:
* import { createModuleContext } from '$lib/data/module-context';
* import type { Todo } from './types';
*
* export const todosContext = createModuleContext<Todo[]>('todos');
*
* // In +layout.svelte:
* todosContext.provide(useAllTodos());
*
* // In +page.svelte:
* const todos = todosContext.consume();
* // todos is { readonly value: Todo[]; readonly loading: boolean; readonly error: unknown }
* ```
*/
import { setContext, getContext } from 'svelte';
/**
* The shape returned by `useLiveQueryWithDefault` from @mana/local-store/svelte.
* We mirror it here so module-context.ts doesn't need a runtime import
* of the local-store package (it's types-only).
*/
export interface LiveQueryResult<T> {
readonly value: T;
readonly loading: boolean;
readonly error: unknown;
}
/**
* Creates a typed context pair for a module's reactive data.
*
* @param key — unique string key for the Svelte context. Must match
* across the layout (provide) and page (consume) in the same route
* subtree.
* @returns `{ provide, consume }` — call `provide` in the layout's
* `<script>` block and `consume` in any descendant page/component.
*/
export function createModuleContext<T>(key: string) {
return {
/**
* Set the reactive query result into the Svelte context.
* Call this in the `<script>` block of a +layout.svelte.
*/
provide(value: LiveQueryResult<T>): void {
setContext(key, value);
},
/**
* Read the reactive query result from the Svelte context.
* Call this in the `<script>` block of a +page.svelte or component.
*/
consume(): LiveQueryResult<T> {
return getContext<LiveQueryResult<T>>(key);
},
};
}