From fe24e8bdd758112fd5486703c047d772f3c99fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Thu, 9 Apr 2026 09:32:47 +0200 Subject: [PATCH] feat(06-01): add type contracts, state module, and renderer registry --- assets/src/renderers/registry.ts | 17 ++++++++++++++++ assets/src/renderers/types.ts | 16 +++++++++++++++ assets/src/state.ts | 21 ++++++++++++++++++++ assets/src/types.ts | 9 +++++++++ assets/test/registry.test.ts | 34 ++++++++++++++++++++++++++++++++ assets/test/state.test.ts | 26 ++++++++++++++++++++++++ 6 files changed, 123 insertions(+) create mode 100644 assets/src/renderers/registry.ts create mode 100644 assets/src/renderers/types.ts create mode 100644 assets/src/state.ts create mode 100644 assets/test/registry.test.ts create mode 100644 assets/test/state.test.ts diff --git a/assets/src/renderers/registry.ts b/assets/src/renderers/registry.ts new file mode 100644 index 0000000..2212a8b --- /dev/null +++ b/assets/src/renderers/registry.ts @@ -0,0 +1,17 @@ +import type { ModeRenderer } from './types'; + +const renderers = new Map(); + +export function registerRenderer(renderer: ModeRenderer): void { + renderers.set(renderer.mode, renderer); +} + +export function getRenderer(mode: string): ModeRenderer { + const r = renderers.get(mode); + if (!r) throw new Error(`Unknown heatmap mode: ${mode}`); + return r; +} + +export function clearRegistry(): void { + renderers.clear(); +} diff --git a/assets/src/renderers/types.ts b/assets/src/renderers/types.ts new file mode 100644 index 0000000..e3b0882 --- /dev/null +++ b/assets/src/renderers/types.ts @@ -0,0 +1,16 @@ +import type { HeatmapData, HeatmapConfig } from '../types'; +import type { HeatmapState } from '../state'; + +export interface RenderContext { + container: HTMLElement; + data: HeatmapData; + state: HeatmapState; + config: HeatmapConfig; + onCellClick?: (dateStr: string) => void; +} + +export interface ModeRenderer { + readonly mode: string; + render(ctx: RenderContext): void; + destroy?(): void; +} diff --git a/assets/src/state.ts b/assets/src/state.ts new file mode 100644 index 0000000..b3cd5d8 --- /dev/null +++ b/assets/src/state.ts @@ -0,0 +1,21 @@ +import type { HeatmapData, HeatmapMode, DisplayMetric, FilterState } from './types'; + +export type { HeatmapMode, DisplayMetric, FilterState }; + +export interface HeatmapState { + mode: HeatmapMode; + metric: DisplayMetric; + filters: FilterState; + weekStart: string; + data: HeatmapData | null; +} + +export function createInitialState(weekStart: string): HeatmapState { + return { + mode: 'year', + metric: 'hours', + filters: { projectId: null, customerId: null, activityId: null }, + weekStart, + data: null, + }; +} diff --git a/assets/src/types.ts b/assets/src/types.ts index e7f90dd..254b3b9 100644 --- a/assets/src/types.ts +++ b/assets/src/types.ts @@ -24,3 +24,12 @@ export interface ProjectOption { id: number; name: string; } + +export type DisplayMetric = 'hours' | 'count'; +export type HeatmapMode = 'year' | 'week' | 'day' | 'combined'; + +export interface FilterState { + projectId: number | null; + customerId: number | null; + activityId: number | null; +} diff --git a/assets/test/registry.test.ts b/assets/test/registry.test.ts new file mode 100644 index 0000000..68fa03c --- /dev/null +++ b/assets/test/registry.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { registerRenderer, getRenderer, clearRegistry } from '../src/renderers/registry'; +import type { ModeRenderer, RenderContext } from '../src/renderers/types'; + +function makeMockRenderer(mode: string): ModeRenderer { + return { + mode, + render(_ctx: RenderContext): void {}, + }; +} + +describe('renderer registry', () => { + beforeEach(() => { + clearRegistry(); + }); + + it('registers and retrieves a renderer by mode', () => { + const renderer = makeMockRenderer('year'); + registerRenderer(renderer); + expect(getRenderer('year')).toBe(renderer); + }); + + it('throws for unknown mode', () => { + expect(() => getRenderer('nonexistent')).toThrowError('Unknown heatmap mode'); + }); + + it('overwrites renderer for same mode', () => { + const first = makeMockRenderer('year'); + const second = makeMockRenderer('year'); + registerRenderer(first); + registerRenderer(second); + expect(getRenderer('year')).toBe(second); + }); +}); diff --git a/assets/test/state.test.ts b/assets/test/state.test.ts new file mode 100644 index 0000000..e1289f4 --- /dev/null +++ b/assets/test/state.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { createInitialState } from '../src/state'; + +describe('createInitialState', () => { + it('returns correct defaults for monday start', () => { + const state = createInitialState('monday'); + expect(state).toEqual({ + mode: 'year', + metric: 'hours', + filters: { projectId: null, customerId: null, activityId: null }, + weekStart: 'monday', + data: null, + }); + }); + + it('returns correct defaults for sunday start', () => { + const state = createInitialState('sunday'); + expect(state).toEqual({ + mode: 'year', + metric: 'hours', + filters: { projectId: null, customerId: null, activityId: null }, + weekStart: 'sunday', + data: null, + }); + }); +});