docs(phase-6): create phase plan
This commit is contained in:
parent
e7d12719ed
commit
a0d9bbfcc3
3 changed files with 1139 additions and 2 deletions
|
|
@ -37,7 +37,10 @@
|
|||
2. Tooltip, color scale, and cell click handler are extracted as shared utilities reusable by any renderer
|
||||
3. A HeatmapState object tracks mode, display metric, and filters -- UI changes flow through state
|
||||
4. Adding a new visualization mode requires only implementing a ModeRenderer interface and registering it
|
||||
**Plans**: TBD
|
||||
**Plans:** 2 plans
|
||||
Plans:
|
||||
- [ ] 06-01-PLAN.md — Type contracts, state, registry, and shared utility extraction
|
||||
- [ ] 06-02-PLAN.md — YearModeRenderer, orchestrator rewrite, test migration, visual check
|
||||
|
||||
### Phase 7: Mode Switcher + Week Mode
|
||||
**Goal**: Users can switch between year and week visualization modes and toggle between hours and entry-count display
|
||||
|
|
@ -102,7 +105,7 @@ Note: Phases 7 and 8 can execute in parallel (both depend only on Phase 6).
|
|||
| 3. Core Heatmap Rendering | v1.0 | 3/3 | Complete | 2026-04-08 |
|
||||
| 4. Heatmap Interaction | v1.0 | 2/2 | Complete | 2026-04-08 |
|
||||
| 5. Polish | v1.0 | 2/2 | Complete | 2026-04-08 |
|
||||
| 6. Renderer Architecture | v1.1 | 0/? | Not started | - |
|
||||
| 6. Renderer Architecture | v1.1 | 0/2 | Planned | - |
|
||||
| 7. Mode Switcher + Week Mode | v1.1 | 0/? | Not started | - |
|
||||
| 8. Backend Aggregation + Filtering | v1.1 | 0/? | Not started | - |
|
||||
| 9. Day + Combined Modes | v1.1 | 0/? | Not started | - |
|
||||
|
|
|
|||
455
.planning/phases/06-renderer-architecture/06-01-PLAN.md
Normal file
455
.planning/phases/06-renderer-architecture/06-01-PLAN.md
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
---
|
||||
phase: 06-renderer-architecture
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- assets/src/types.ts
|
||||
- assets/src/state.ts
|
||||
- assets/src/renderers/types.ts
|
||||
- assets/src/renderers/registry.ts
|
||||
- assets/src/shared/tooltip.ts
|
||||
- assets/src/shared/color-scale.ts
|
||||
- assets/src/shared/stats.ts
|
||||
- assets/src/shared/date-utils.ts
|
||||
- assets/test/state.test.ts
|
||||
- assets/test/registry.test.ts
|
||||
- assets/test/tooltip.test.ts
|
||||
- assets/test/color-scale.test.ts
|
||||
- assets/test/date-utils.test.ts
|
||||
autonomous: true
|
||||
requirements: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "ModeRenderer interface and RenderContext type are exported from renderers/types.ts"
|
||||
- "HeatmapState can be created with createInitialState() and defaults to year mode, hours metric"
|
||||
- "Renderer registry accepts registration and retrieves by mode string"
|
||||
- "Tooltip create/show/hide functions work identically to inline code in v1.0 heatmap.ts"
|
||||
- "Color scale builds correctly for both hours and count metrics"
|
||||
- "Stats functions (calculateStreak, calculateStats, renderStats) produce identical output from new location"
|
||||
- "Date utility functions (buildDateMap, generateCells, getWeekInterval) produce identical output from new location"
|
||||
artifacts:
|
||||
- path: "assets/src/renderers/types.ts"
|
||||
provides: "ModeRenderer interface, RenderContext type"
|
||||
exports: ["ModeRenderer", "RenderContext"]
|
||||
- path: "assets/src/state.ts"
|
||||
provides: "HeatmapState creation"
|
||||
exports: ["createInitialState", "HeatmapState", "HeatmapMode", "DisplayMetric", "FilterState"]
|
||||
- path: "assets/src/renderers/registry.ts"
|
||||
provides: "Renderer registration and lookup"
|
||||
exports: ["registerRenderer", "getRenderer"]
|
||||
- path: "assets/src/shared/tooltip.ts"
|
||||
provides: "Tooltip lifecycle functions"
|
||||
exports: ["createTooltip", "showTooltip", "hideTooltip"]
|
||||
- path: "assets/src/shared/color-scale.ts"
|
||||
provides: "Color scale factory"
|
||||
exports: ["buildColorScale", "FALLBACK_COLORS"]
|
||||
- path: "assets/src/shared/stats.ts"
|
||||
provides: "Stats calculation and rendering"
|
||||
exports: ["calculateStreak", "calculateStats", "renderStats", "HeatmapStats"]
|
||||
- path: "assets/src/shared/date-utils.ts"
|
||||
provides: "Date grid utilities"
|
||||
exports: ["buildDateMap", "generateCells", "getWeekInterval", "DayCell", "DATE_FORMAT", "DISPLAY_FORMAT"]
|
||||
key_links:
|
||||
- from: "assets/src/shared/color-scale.ts"
|
||||
to: "assets/src/types.ts"
|
||||
via: "imports DayEntry, DisplayMetric"
|
||||
pattern: "import.*DayEntry.*DisplayMetric.*from"
|
||||
- from: "assets/src/renderers/types.ts"
|
||||
to: "assets/src/types.ts"
|
||||
via: "imports HeatmapData, HeatmapConfig"
|
||||
pattern: "import.*HeatmapData.*HeatmapConfig.*from"
|
||||
- from: "assets/src/renderers/types.ts"
|
||||
to: "assets/src/state.ts"
|
||||
via: "imports HeatmapState"
|
||||
pattern: "import.*HeatmapState.*from"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create all building blocks for the strategy-pattern renderer system: type definitions, state management, renderer registry, and extracted shared utilities.
|
||||
|
||||
Purpose: Establish the module structure and contracts that Plan 02 will wire together. Every function extracted here already exists in heatmap.ts -- this is pure decomposition.
|
||||
Output: 8 new source files + 5 new test files. Zero changes to runtime behavior.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/toph/code/toph/kimai-heatmap/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/toph/code/toph/kimai-heatmap/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-renderer-architecture/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Current types from assets/src/types.ts that all new modules will reference -->
|
||||
From assets/src/types.ts:
|
||||
```typescript
|
||||
export interface DayEntry {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
hours: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HeatmapData {
|
||||
days: DayEntry[];
|
||||
range: {
|
||||
begin: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HeatmapConfig {
|
||||
cellSize: number;
|
||||
cellGap: number;
|
||||
marginTop: number;
|
||||
marginLeft: number;
|
||||
marginBottom: number;
|
||||
}
|
||||
|
||||
export interface ProjectOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
```
|
||||
|
||||
From assets/src/heatmap.ts (functions to extract):
|
||||
```typescript
|
||||
// Lines 47-53: buildDateMap
|
||||
function buildDateMap(days: DayEntry[]): Map<string, DayEntry> { ... }
|
||||
|
||||
// Lines 55-57: getWeekInterval
|
||||
function getWeekInterval(weekStart: string) { ... }
|
||||
|
||||
// Lines 59-92: generateCells (returns DayCell[])
|
||||
function generateCells(begin: Date, end: Date, dateMap: Map<string, DayEntry>, weekStart: string = 'monday'): DayCell[] { ... }
|
||||
|
||||
// Lines 94-99: createTooltip
|
||||
function createTooltip(): HTMLDivElement { ... }
|
||||
|
||||
// Lines 101-125: calculateStreak (exported)
|
||||
export function calculateStreak(days: DayEntry[]): number { ... }
|
||||
|
||||
// Lines 127-131: HeatmapStats interface (exported)
|
||||
export interface HeatmapStats { ... }
|
||||
|
||||
// Lines 133-148: calculateStats (exported)
|
||||
export function calculateStats(days: DayEntry[]): HeatmapStats { ... }
|
||||
|
||||
// Lines 150-174: renderStats
|
||||
function renderStats(container: HTMLElement, days: DayEntry[]): void { ... }
|
||||
|
||||
// Lines 200-205: color scale setup (inline in renderHeatmap)
|
||||
const colorScale = scaleQuantize<string>().domain([0, maxHours]).range(colors);
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create type contracts, state module, and renderer registry</name>
|
||||
<files>
|
||||
assets/src/types.ts,
|
||||
assets/src/state.ts,
|
||||
assets/src/renderers/types.ts,
|
||||
assets/src/renderers/registry.ts,
|
||||
assets/test/state.test.ts,
|
||||
assets/test/registry.test.ts
|
||||
</files>
|
||||
<read_first>
|
||||
assets/src/types.ts,
|
||||
assets/src/heatmap.ts
|
||||
</read_first>
|
||||
<behavior>
|
||||
- state.test.ts: createInitialState('monday') returns { mode: 'year', metric: 'hours', filters: { projectId: null, customerId: null, activityId: null }, weekStart: 'monday', data: null }
|
||||
- state.test.ts: createInitialState('sunday') returns state with weekStart: 'sunday'
|
||||
- registry.test.ts: registerRenderer(renderer) then getRenderer('year') returns that renderer
|
||||
- registry.test.ts: getRenderer('nonexistent') throws Error with message containing 'Unknown heatmap mode'
|
||||
- registry.test.ts: registering a second renderer for same mode overwrites the first
|
||||
</behavior>
|
||||
<action>
|
||||
1. **assets/src/types.ts** -- Add new types alongside existing ones (do NOT remove existing exports):
|
||||
```typescript
|
||||
export type DisplayMetric = 'hours' | 'count';
|
||||
export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
|
||||
|
||||
export interface FilterState {
|
||||
projectId: number | null;
|
||||
customerId: number | null;
|
||||
activityId: number | null;
|
||||
}
|
||||
```
|
||||
Keep all existing interfaces (DayEntry, HeatmapData, HeatmapConfig, ProjectOption) unchanged.
|
||||
|
||||
2. **assets/src/state.ts** -- Create state factory:
|
||||
```typescript
|
||||
import type { HeatmapData, HeatmapMode, DisplayMetric, FilterState } from './types';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
```
|
||||
Re-export HeatmapState from this file. Keep it as a plain interface, not a class.
|
||||
|
||||
3. **assets/src/renderers/types.ts** -- Create renderer contracts:
|
||||
```typescript
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
4. **assets/src/renderers/registry.ts** -- Create registry:
|
||||
```typescript
|
||||
import type { ModeRenderer } from './types';
|
||||
|
||||
const renderers = new Map<string, ModeRenderer>();
|
||||
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
5. **assets/test/state.test.ts** -- Write tests first (TDD red), then verify green:
|
||||
- Test createInitialState returns correct default shape for 'monday' and 'sunday'
|
||||
- Test that data is null, mode is 'year', metric is 'hours'
|
||||
|
||||
6. **assets/test/registry.test.ts** -- Write tests first (TDD red), then verify green:
|
||||
- Test registerRenderer + getRenderer roundtrip
|
||||
- Test getRenderer throws for unknown mode
|
||||
- Test overwrite behavior
|
||||
- IMPORTANT: After each test run, clear the registry. Since the registry uses module-level state, add a `clearRegistry()` export for testing, or create a fresh mock renderer per test. Simplest: add `export function clearRegistry(): void { renderers.clear(); }` to registry.ts and call it in beforeEach.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run assets/test/state.test.ts assets/test/registry.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- assets/src/types.ts contains `export type DisplayMetric = 'hours' | 'count'`
|
||||
- assets/src/types.ts contains `export type HeatmapMode = 'year' | 'week' | 'day' | 'combined'`
|
||||
- assets/src/types.ts contains `export interface FilterState`
|
||||
- assets/src/types.ts still contains `export interface DayEntry` (not removed)
|
||||
- assets/src/state.ts contains `export function createInitialState`
|
||||
- assets/src/state.ts contains `export interface HeatmapState`
|
||||
- assets/src/renderers/types.ts contains `export interface ModeRenderer`
|
||||
- assets/src/renderers/types.ts contains `export interface RenderContext`
|
||||
- assets/src/renderers/registry.ts contains `export function registerRenderer`
|
||||
- assets/src/renderers/registry.ts contains `export function getRenderer`
|
||||
- assets/test/state.test.ts exits 0
|
||||
- assets/test/registry.test.ts exits 0
|
||||
- Existing tests still pass: `npx vitest run assets/test/stats.test.ts` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
All new type/state/registry modules exist with correct exports. New tests pass. Existing tests unaffected (heatmap.ts unchanged yet).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Extract shared utilities from heatmap.ts</name>
|
||||
<files>
|
||||
assets/src/shared/tooltip.ts,
|
||||
assets/src/shared/color-scale.ts,
|
||||
assets/src/shared/stats.ts,
|
||||
assets/src/shared/date-utils.ts,
|
||||
assets/test/tooltip.test.ts,
|
||||
assets/test/color-scale.test.ts,
|
||||
assets/test/date-utils.test.ts
|
||||
</files>
|
||||
<read_first>
|
||||
assets/src/heatmap.ts,
|
||||
assets/src/types.ts,
|
||||
assets/src/state.ts
|
||||
</read_first>
|
||||
<behavior>
|
||||
- tooltip.test.ts: createTooltip() returns HTMLDivElement with className 'heatmap-tooltip' and display 'none'
|
||||
- tooltip.test.ts: createTooltip() removes any existing .heatmap-tooltip elements before creating new one
|
||||
- tooltip.test.ts: showTooltip() sets display to 'block' and positions tooltip
|
||||
- tooltip.test.ts: hideTooltip() sets display to 'none'
|
||||
- color-scale.test.ts: buildColorScale with hours metric returns scaleQuantize using hours values
|
||||
- color-scale.test.ts: buildColorScale with count metric returns scaleQuantize using count values
|
||||
- color-scale.test.ts: buildColorScale with empty array returns scale with domain [0, 1]
|
||||
- date-utils.test.ts: buildDateMap creates Map keyed by date string
|
||||
- date-utils.test.ts: generateCells returns correct count for date range
|
||||
- date-utils.test.ts: generateCells marks weekend cells correctly
|
||||
- date-utils.test.ts: getWeekInterval returns timeMonday for 'monday', timeSunday for 'sunday'
|
||||
</behavior>
|
||||
<action>
|
||||
Extract functions from heatmap.ts into new modules. At this point, do NOT modify heatmap.ts -- only create the new shared/ files with copied (not moved) logic. Plan 02 will do the actual switchover.
|
||||
|
||||
1. **assets/src/shared/tooltip.ts** -- Extract from heatmap.ts lines 94-99 and the inline tooltip logic at lines 262-296:
|
||||
```typescript
|
||||
export function createTooltip(): HTMLDivElement {
|
||||
// Clean up stale tooltips (from heatmap.ts line 263)
|
||||
document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());
|
||||
|
||||
const tip = document.createElement('div');
|
||||
tip.className = 'heatmap-tooltip';
|
||||
tip.style.display = 'none';
|
||||
tip.style.position = 'fixed';
|
||||
document.body.appendChild(tip);
|
||||
return tip;
|
||||
}
|
||||
|
||||
export function showTooltip(
|
||||
tip: HTMLDivElement,
|
||||
html: string,
|
||||
anchorRect: DOMRect,
|
||||
cellSize: number,
|
||||
): void {
|
||||
tip.innerHTML = html;
|
||||
tip.style.display = 'block';
|
||||
tip.style.left = `${anchorRect.left + cellSize / 2}px`;
|
||||
tip.style.top = `${anchorRect.top - tip.offsetHeight - 8}px`;
|
||||
}
|
||||
|
||||
export function hideTooltip(tip: HTMLDivElement): void {
|
||||
tip.style.display = 'none';
|
||||
}
|
||||
```
|
||||
Note: createTooltip in the new module appends to document.body and sets position: fixed (consolidating the scattered tooltip setup from renderHeatmap). The old createTooltip in heatmap.ts did NOT append or set position -- that was done inline. The new version consolidates both.
|
||||
|
||||
2. **assets/src/shared/color-scale.ts** -- Extract from heatmap.ts lines 16, 200-205:
|
||||
```typescript
|
||||
import { scaleQuantize } from 'd3-scale';
|
||||
import { max } from 'd3-array';
|
||||
import type { DayEntry, DisplayMetric } from '../types';
|
||||
|
||||
export const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39'];
|
||||
|
||||
export function buildColorScale(
|
||||
days: DayEntry[],
|
||||
metric: DisplayMetric = 'hours',
|
||||
): ReturnType<typeof scaleQuantize<string>> {
|
||||
const accessor = metric === 'hours'
|
||||
? (d: DayEntry) => d.hours
|
||||
: (d: DayEntry) => d.count;
|
||||
const maxVal = max(days, accessor) || 1;
|
||||
return scaleQuantize<string>().domain([0, maxVal]).range(FALLBACK_COLORS);
|
||||
}
|
||||
```
|
||||
The resolveColors() function from heatmap.ts always returns FALLBACK_COLORS (the Tabler check is a no-op). Do not replicate resolveColors -- just use FALLBACK_COLORS directly.
|
||||
|
||||
3. **assets/src/shared/stats.ts** -- Extract from heatmap.ts lines 101-174:
|
||||
Copy calculateStreak, HeatmapStats interface, calculateStats, and renderStats verbatim. These functions need these imports:
|
||||
```typescript
|
||||
import { timeDay } from 'd3-time';
|
||||
import { timeFormat } from 'd3-time-format';
|
||||
import type { DayEntry } from '../types';
|
||||
```
|
||||
The DATE_FORMAT and DISPLAY_FORMAT constants used by stats (lines 24-25 of heatmap.ts) must also be available. Define them locally in stats.ts:
|
||||
```typescript
|
||||
const DATE_FORMAT = timeFormat('%Y-%m-%d');
|
||||
const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y');
|
||||
```
|
||||
|
||||
4. **assets/src/shared/date-utils.ts** -- Extract from heatmap.ts lines 20-92:
|
||||
Copy DayCell interface, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY, getDayLabels, MONTH_FORMAT, DATE_FORMAT, DISPLAY_FORMAT, buildDateMap, getWeekInterval, generateCells. Exports:
|
||||
```typescript
|
||||
export { DayCell, buildDateMap, getWeekInterval, generateCells, getDayLabels, DATE_FORMAT, DISPLAY_FORMAT, MONTH_FORMAT, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY };
|
||||
```
|
||||
Imports needed:
|
||||
```typescript
|
||||
import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time';
|
||||
import { timeFormat } from 'd3-time-format';
|
||||
import type { DayEntry } from '../types';
|
||||
```
|
||||
|
||||
5. Write test files FIRST (TDD red), then create source files to make them green. For each test file, import from the new shared/ module path.
|
||||
|
||||
6. After all new tests pass, run existing tests to confirm they still pass (heatmap.ts is unchanged, so they must).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run assets/test/tooltip.test.ts assets/test/color-scale.test.ts assets/test/date-utils.test.ts && npx vitest run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- assets/src/shared/tooltip.ts contains `export function createTooltip`
|
||||
- assets/src/shared/tooltip.ts contains `export function showTooltip`
|
||||
- assets/src/shared/tooltip.ts contains `export function hideTooltip`
|
||||
- assets/src/shared/color-scale.ts contains `export const FALLBACK_COLORS`
|
||||
- assets/src/shared/color-scale.ts contains `export function buildColorScale`
|
||||
- assets/src/shared/color-scale.ts contains `import type { DayEntry, DisplayMetric }`
|
||||
- assets/src/shared/stats.ts contains `export function calculateStreak`
|
||||
- assets/src/shared/stats.ts contains `export function calculateStats`
|
||||
- assets/src/shared/stats.ts contains `export function renderStats`
|
||||
- assets/src/shared/stats.ts contains `export interface HeatmapStats`
|
||||
- assets/src/shared/date-utils.ts contains `export function buildDateMap`
|
||||
- assets/src/shared/date-utils.ts contains `export function generateCells`
|
||||
- assets/src/shared/date-utils.ts contains `export function getWeekInterval`
|
||||
- assets/src/shared/date-utils.ts contains `export interface DayCell`
|
||||
- All new test files exit 0: `npx vitest run assets/test/tooltip.test.ts assets/test/color-scale.test.ts assets/test/date-utils.test.ts`
|
||||
- All existing tests still pass: `npx vitest run` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
All shared utility modules exist with correct exports and passing tests. heatmap.ts is unchanged -- the new modules are copies, not yet wired in. Full test suite green.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> Plugin JS | Plugin JS consumes API data and renders SVG. No new boundaries in this refactor. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-01 | T (Tampering) | shared/tooltip.ts | accept | Tooltip uses innerHTML with data already trusted (from own API response). No new attack surface -- identical to v1.0 code. XSS risk is pre-existing and out of scope for a refactor phase. |
|
||||
| T-06-02 | D (Denial of Service) | renderers/registry.ts | accept | Registry is module-level Map populated at import time with known renderers. No user input reaches it. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx vitest run` -- all tests pass (existing + new)
|
||||
2. `npm run build` -- esbuild produces heatmap.js without errors (heatmap.ts unchanged, new files are standalone)
|
||||
3. New modules are importable: each test file successfully imports from shared/ and renderers/ paths
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 8 new source files created under assets/src/ (state.ts, renderers/types.ts, renderers/registry.ts, shared/tooltip.ts, shared/color-scale.ts, shared/stats.ts, shared/date-utils.ts, plus types.ts extended)
|
||||
- 5 new test files pass (state, registry, tooltip, color-scale, date-utils)
|
||||
- 4 existing test files still pass (heatmap, stats, interaction, filter)
|
||||
- esbuild still produces valid output
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-renderer-architecture/06-01-SUMMARY.md`
|
||||
</output>
|
||||
679
.planning/phases/06-renderer-architecture/06-02-PLAN.md
Normal file
679
.planning/phases/06-renderer-architecture/06-02-PLAN.md
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
---
|
||||
phase: 06-renderer-architecture
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["06-01"]
|
||||
files_modified:
|
||||
- assets/src/heatmap.ts
|
||||
- assets/src/renderers/year.ts
|
||||
- assets/src/renderers/types.ts
|
||||
- assets/test/heatmap.test.ts
|
||||
- assets/test/stats.test.ts
|
||||
- assets/test/interaction.test.ts
|
||||
- assets/test/filter.test.ts
|
||||
autonomous: false
|
||||
requirements: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Year-view heatmap renders identically to v1.0 (same SVG structure, classes, attributes)"
|
||||
- "Tooltip, color scale, and cell click handler come from shared utilities, not inline code"
|
||||
- "HeatmapState object tracks mode, display metric, and filters"
|
||||
- "Adding a new mode requires only implementing ModeRenderer and calling registerRenderer()"
|
||||
- "KimaiHeatmap.init global entry point works unchanged in browser"
|
||||
- "All existing test behaviors pass from new import locations"
|
||||
artifacts:
|
||||
- path: "assets/src/renderers/year.ts"
|
||||
provides: "YearModeRenderer implementing ModeRenderer"
|
||||
exports: ["YearModeRenderer"]
|
||||
- path: "assets/src/heatmap.ts"
|
||||
provides: "Slim orchestrator with init() and doRender()"
|
||||
exports: ["init"]
|
||||
- path: "Resources/public/heatmap.js"
|
||||
provides: "Built IIFE bundle with KimaiHeatmap.init"
|
||||
key_links:
|
||||
- from: "assets/src/heatmap.ts"
|
||||
to: "assets/src/renderers/registry.ts"
|
||||
via: "getRenderer(state.mode)"
|
||||
pattern: "getRenderer\\(state\\.mode\\)"
|
||||
- from: "assets/src/heatmap.ts"
|
||||
to: "assets/src/renderers/year.ts"
|
||||
via: "registerRenderer(new YearModeRenderer())"
|
||||
pattern: "registerRenderer.*YearModeRenderer"
|
||||
- from: "assets/src/renderers/year.ts"
|
||||
to: "assets/src/shared/tooltip.ts"
|
||||
via: "imports createTooltip, showTooltip, hideTooltip"
|
||||
pattern: "import.*createTooltip.*from.*shared/tooltip"
|
||||
- from: "assets/src/renderers/year.ts"
|
||||
to: "assets/src/shared/color-scale.ts"
|
||||
via: "imports buildColorScale"
|
||||
pattern: "import.*buildColorScale.*from.*shared/color-scale"
|
||||
- from: "assets/src/renderers/year.ts"
|
||||
to: "assets/src/shared/date-utils.ts"
|
||||
via: "imports generateCells, buildDateMap, getWeekInterval"
|
||||
pattern: "import.*generateCells.*from.*shared/date-utils"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the building blocks from Plan 01 into a working strategy-pattern system: create YearModeRenderer, rewrite heatmap.ts as a slim orchestrator, and migrate all test imports.
|
||||
|
||||
Purpose: Complete the refactor so that heatmap.ts dispatches rendering through the registry and the year-view works identically to v1.0.
|
||||
Output: Working renderer architecture with all tests passing from new module locations.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/toph/code/toph/kimai-heatmap/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/toph/code/toph/kimai-heatmap/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-renderer-architecture/06-RESEARCH.md
|
||||
@.planning/phases/06-renderer-architecture/06-UI-SPEC.md
|
||||
@.planning/phases/06-renderer-architecture/06-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 outputs -->
|
||||
From assets/src/renderers/types.ts:
|
||||
```typescript
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
From assets/src/state.ts:
|
||||
```typescript
|
||||
export interface HeatmapState {
|
||||
mode: HeatmapMode;
|
||||
metric: DisplayMetric;
|
||||
filters: FilterState;
|
||||
weekStart: string;
|
||||
data: HeatmapData | null;
|
||||
}
|
||||
|
||||
export function createInitialState(weekStart: string): HeatmapState;
|
||||
```
|
||||
|
||||
From assets/src/renderers/registry.ts:
|
||||
```typescript
|
||||
export function registerRenderer(renderer: ModeRenderer): void;
|
||||
export function getRenderer(mode: string): ModeRenderer;
|
||||
```
|
||||
|
||||
From assets/src/shared/tooltip.ts:
|
||||
```typescript
|
||||
export function createTooltip(): HTMLDivElement;
|
||||
export function showTooltip(tip: HTMLDivElement, html: string, anchorRect: DOMRect, cellSize: number): void;
|
||||
export function hideTooltip(tip: HTMLDivElement): void;
|
||||
```
|
||||
|
||||
From assets/src/shared/color-scale.ts:
|
||||
```typescript
|
||||
export const FALLBACK_COLORS: string[];
|
||||
export function buildColorScale(days: DayEntry[], metric?: DisplayMetric): ReturnType<typeof scaleQuantize<string>>;
|
||||
```
|
||||
|
||||
From assets/src/shared/stats.ts:
|
||||
```typescript
|
||||
export function calculateStreak(days: DayEntry[]): number;
|
||||
export function calculateStats(days: DayEntry[]): HeatmapStats;
|
||||
export interface HeatmapStats { totalHours: number; avgHours: number; busiestDay: { date: string; hours: number } | null; }
|
||||
export function renderStats(container: HTMLElement, days: DayEntry[]): void;
|
||||
```
|
||||
|
||||
From assets/src/shared/date-utils.ts:
|
||||
```typescript
|
||||
export interface DayCell { date: Date; dateStr: string; entry: DayEntry | null; week: number; day: number; isWeekend: boolean; }
|
||||
export function buildDateMap(days: DayEntry[]): Map<string, DayEntry>;
|
||||
export function getWeekInterval(weekStart: string): typeof timeMonday;
|
||||
export function generateCells(begin: Date, end: Date, dateMap: Map<string, DayEntry>, weekStart?: string): DayCell[];
|
||||
export function getDayLabels(weekStart: string): string[];
|
||||
export const DATE_FORMAT: (d: Date) => string;
|
||||
export const DISPLAY_FORMAT: (d: Date) => string;
|
||||
export const MONTH_FORMAT: (d: Date) => string;
|
||||
```
|
||||
|
||||
From assets/src/types.ts:
|
||||
```typescript
|
||||
export interface DayEntry { date: string; hours: number; count: number; }
|
||||
export interface HeatmapData { days: DayEntry[]; range: { begin: string; end: string; }; }
|
||||
export interface HeatmapConfig { cellSize: number; cellGap: number; marginTop: number; marginLeft: number; marginBottom: number; }
|
||||
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; }
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create YearModeRenderer and rewrite heatmap.ts orchestrator</name>
|
||||
<files>
|
||||
assets/src/renderers/year.ts,
|
||||
assets/src/renderers/types.ts,
|
||||
assets/src/heatmap.ts
|
||||
</files>
|
||||
<read_first>
|
||||
assets/src/heatmap.ts,
|
||||
assets/src/renderers/types.ts,
|
||||
assets/src/state.ts,
|
||||
assets/src/renderers/registry.ts,
|
||||
assets/src/shared/tooltip.ts,
|
||||
assets/src/shared/color-scale.ts,
|
||||
assets/src/shared/stats.ts,
|
||||
assets/src/shared/date-utils.ts,
|
||||
assets/src/types.ts
|
||||
</read_first>
|
||||
<action>
|
||||
**Part A: Update assets/src/renderers/types.ts -- add emptyMessage to RenderContext**
|
||||
|
||||
Add an optional `emptyMessage?: string;` field to the RenderContext interface (after onCellClick). This preserves the filtered empty message behavior from v1.0 where "No tracking data for this project" was shown when filtering produced empty results.
|
||||
|
||||
**Part B: Create assets/src/renderers/year.ts**
|
||||
|
||||
Extract the body of `renderHeatmap()` (lines 176-302 of current heatmap.ts) into a `YearModeRenderer` class implementing `ModeRenderer`. The render() method must produce IDENTICAL SVG output.
|
||||
|
||||
Structure:
|
||||
```typescript
|
||||
import { select } from 'd3-selection';
|
||||
import { timeMonth } from 'd3-time';
|
||||
import { max } from 'd3-array';
|
||||
import type { ModeRenderer, RenderContext } from './types';
|
||||
import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip';
|
||||
import { buildColorScale } from '../shared/color-scale';
|
||||
import {
|
||||
buildDateMap, generateCells, getWeekInterval, getDayLabels,
|
||||
MONTH_FORMAT, DISPLAY_FORMAT, type DayCell,
|
||||
} from '../shared/date-utils';
|
||||
|
||||
export class YearModeRenderer implements ModeRenderer {
|
||||
readonly mode = 'year';
|
||||
private tooltip: HTMLDivElement | null = null;
|
||||
|
||||
render(ctx: RenderContext): void {
|
||||
ctx.container.innerHTML = '';
|
||||
|
||||
// Handle empty data -- use ctx.emptyMessage if provided, else default
|
||||
if (!ctx.data.days || ctx.data.days.length === 0) {
|
||||
const msg = document.createElement('div');
|
||||
msg.textContent = ctx.emptyMessage || 'No tracking data available';
|
||||
msg.style.padding = '1rem';
|
||||
msg.style.color = 'var(--tblr-secondary, #6c757d)';
|
||||
ctx.container.appendChild(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy previous tooltip
|
||||
this.destroy();
|
||||
this.tooltip = createTooltip();
|
||||
|
||||
const weekStart = ctx.state.weekStart;
|
||||
const dateMap = buildDateMap(ctx.data.days);
|
||||
const begin = new Date(ctx.data.range.begin);
|
||||
const end = new Date(ctx.data.range.end);
|
||||
const cells = generateCells(begin, end, dateMap, weekStart);
|
||||
|
||||
// Build color scale using state metric (hours vs count)
|
||||
const colorScale = buildColorScale(ctx.data.days, ctx.state.metric);
|
||||
|
||||
const { cellGap, marginTop, marginLeft, marginBottom } = ctx.config;
|
||||
const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;
|
||||
|
||||
// Compute cell size to fill available width, capped
|
||||
const containerWidth = ctx.container.clientWidth || 800;
|
||||
const maxCellSize = 22;
|
||||
const minCellSize = 10;
|
||||
const cellSize = Math.min(maxCellSize, Math.max(minCellSize, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
|
||||
const step = cellSize + cellGap;
|
||||
const svgWidth = marginLeft + numWeeks * step;
|
||||
const svgHeight = marginTop + 7 * step + marginBottom;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.maxWidth = `${svgWidth}px`;
|
||||
wrapper.style.margin = '0 auto';
|
||||
ctx.container.appendChild(wrapper);
|
||||
|
||||
const svg = select(wrapper)
|
||||
.append('svg')
|
||||
.attr('width', svgWidth)
|
||||
.attr('height', svgHeight)
|
||||
.attr('class', 'heatmap-svg');
|
||||
|
||||
// Month labels
|
||||
const weekInterval = getWeekInterval(weekStart);
|
||||
const months: { date: Date; week: number }[] = [];
|
||||
const firstWeekDay = weekInterval.floor(begin);
|
||||
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
|
||||
months.push({ date: m, week: weekInterval.count(firstWeekDay, m) });
|
||||
});
|
||||
|
||||
svg.selectAll('.month-label')
|
||||
.data(months)
|
||||
.join('text')
|
||||
.attr('class', 'heatmap-label month-label')
|
||||
.attr('x', (d) => marginLeft + d.week * step)
|
||||
.attr('y', marginTop - 6)
|
||||
.text((d) => MONTH_FORMAT(d.date));
|
||||
|
||||
// Day labels
|
||||
svg.selectAll('.day-label')
|
||||
.data(getDayLabels(weekStart))
|
||||
.join('text')
|
||||
.attr('class', 'heatmap-label day-label')
|
||||
.attr('x', marginLeft - 6)
|
||||
.attr('y', (_d, i) => marginTop + i * step + cellSize - 2)
|
||||
.attr('text-anchor', 'end')
|
||||
.text((d) => d);
|
||||
|
||||
// Cells with tooltip and click
|
||||
const tooltip = this.tooltip;
|
||||
svg.selectAll('.heatmap-cell')
|
||||
.data(cells)
|
||||
.join('rect')
|
||||
.attr('class', (d) => {
|
||||
let cls = 'heatmap-cell';
|
||||
if (!d.entry) cls += ' heatmap-empty';
|
||||
if (d.isWeekend) cls += ' heatmap-weekend';
|
||||
return cls;
|
||||
})
|
||||
.attr('x', (d) => marginLeft + d.week * step)
|
||||
.attr('y', (d) => marginTop + d.day * step)
|
||||
.attr('width', cellSize)
|
||||
.attr('height', cellSize)
|
||||
.attr('fill', (d) => (d.entry ? colorScale(d.entry.hours) : ''))
|
||||
.on('mouseenter', function (event: MouseEvent, d: DayCell) {
|
||||
const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0';
|
||||
const count = d.entry ? d.entry.count : 0;
|
||||
const html = `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`;
|
||||
const rect = (event.target as SVGRectElement).getBoundingClientRect();
|
||||
showTooltip(tooltip, html, rect, cellSize);
|
||||
})
|
||||
.on('mouseleave', function () {
|
||||
hideTooltip(tooltip);
|
||||
})
|
||||
.on('click', function (_event: MouseEvent, d: DayCell) {
|
||||
if (!ctx.onCellClick) return;
|
||||
ctx.onCellClick(d.dateStr);
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.tooltip?.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
CRITICAL details preserved from current renderHeatmap():
|
||||
- Cell size computation: `Math.min(22, Math.max(10, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap))`
|
||||
- SVG wrapper div with max-width and margin:0 auto
|
||||
- Cell classes: `heatmap-cell`, `heatmap-empty`, `heatmap-weekend` -- same logic
|
||||
- Cell `rx` and `ry` are NOT set (CSS handles border-radius) -- do NOT add them
|
||||
- Color scale: uses buildColorScale(days, metric). For v1.0 the metric is always 'hours' but the infrastructure is ready for 'count'.
|
||||
- Tooltip HTML: `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`
|
||||
- Empty cell fill: empty string `''` (CSS `.heatmap-empty` handles it)
|
||||
|
||||
NOTE on color scale in cell fill: The current v1.0 code uses `colorScale(d.entry.hours)` directly. The new buildColorScale returns a scale that accepts the metric value. Since the default metric is 'hours', `colorScale(d.entry.hours)` is correct for 'hours' metric. For 'count' metric (future), the renderer would need `colorScale(d.entry.count)`. To handle both, the cell fill should be:
|
||||
```typescript
|
||||
.attr('fill', (d) => {
|
||||
if (!d.entry) return '';
|
||||
const val = ctx.state.metric === 'hours' ? d.entry.hours : d.entry.count;
|
||||
return colorScale(val);
|
||||
})
|
||||
```
|
||||
This is a forward-compatible change that is a no-op for v1.0 (metric is always 'hours').
|
||||
|
||||
**Part C: Rewrite assets/src/heatmap.ts as slim orchestrator**
|
||||
|
||||
Replace the 413-line file with a ~100-line orchestrator:
|
||||
- Remove ALL functions that moved to shared/ modules: buildDateMap, generateCells, getWeekInterval, createTooltip, calculateStreak, calculateStats, renderStats, getDayLabels, resolveColors, renderHeatmap
|
||||
- Remove: DayCell interface, FALLBACK_COLORS, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY, MONTH_FORMAT, DATE_FORMAT, DISPLAY_FORMAT, DEFAULT_CONFIG, HeatmapStats interface
|
||||
- Keep `init()` as the ONLY export (esbuild IIFE entry point)
|
||||
- Import and register YearModeRenderer at module level
|
||||
- Use HeatmapState to track mode/metric/filters
|
||||
|
||||
Complete new heatmap.ts:
|
||||
```typescript
|
||||
import type { HeatmapData, ProjectOption } from './types';
|
||||
import { createInitialState } from './state';
|
||||
import type { HeatmapState } from './state';
|
||||
import { getRenderer, registerRenderer } from './renderers/registry';
|
||||
import { YearModeRenderer } from './renderers/year';
|
||||
import { renderStats } from './shared/stats';
|
||||
|
||||
// Register built-in renderers
|
||||
registerRenderer(new YearModeRenderer());
|
||||
|
||||
export function init(container: HTMLElement): void {
|
||||
const baseUrl = container.getAttribute('data-url');
|
||||
if (!baseUrl) {
|
||||
console.error('KimaiHeatmap: missing data-url attribute');
|
||||
return;
|
||||
}
|
||||
|
||||
const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/';
|
||||
const weekStart = container.getAttribute('data-week-start') || 'monday';
|
||||
const projectsJson = container.getAttribute('data-projects');
|
||||
const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];
|
||||
|
||||
const state: HeatmapState = createInitialState(weekStart);
|
||||
|
||||
const onCellClick = (dateStr: string): void => {
|
||||
const daterange = `${dateStr} - ${dateStr}`;
|
||||
let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`;
|
||||
if (state.filters.projectId) {
|
||||
url += `&projects[]=${state.filters.projectId}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
// Build wrapper layout
|
||||
container.innerHTML = '';
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'heatmap-wrapper';
|
||||
|
||||
const svgArea = document.createElement('div');
|
||||
svgArea.className = 'heatmap-svg-area';
|
||||
wrapper.appendChild(svgArea);
|
||||
|
||||
const doRender = () => {
|
||||
if (!state.data) return;
|
||||
const renderer = getRenderer(state.mode);
|
||||
renderer.destroy?.();
|
||||
renderer.render({
|
||||
container: svgArea,
|
||||
data: state.data,
|
||||
state,
|
||||
config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 },
|
||||
onCellClick,
|
||||
emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined,
|
||||
});
|
||||
renderStats(container, state.data.days);
|
||||
svgArea.scrollLeft = svgArea.scrollWidth;
|
||||
};
|
||||
|
||||
// Build filter dropdown (only if projects exist)
|
||||
if (projects.length > 0) {
|
||||
const filterDiv = document.createElement('div');
|
||||
filterDiv.className = 'heatmap-filter';
|
||||
|
||||
const selectEl = document.createElement('select');
|
||||
selectEl.className = 'form-select form-select-sm';
|
||||
selectEl.setAttribute('aria-label', 'Filter by project');
|
||||
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = 'All Projects';
|
||||
selectEl.appendChild(defaultOpt);
|
||||
|
||||
for (const p of projects) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(p.id);
|
||||
opt.textContent = p.name;
|
||||
selectEl.appendChild(opt);
|
||||
}
|
||||
|
||||
selectEl.addEventListener('change', () => {
|
||||
const val = selectEl.value;
|
||||
state.filters.projectId = val ? parseInt(val, 10) : null;
|
||||
const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl;
|
||||
|
||||
fetch(fetchUrl)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<HeatmapData>;
|
||||
})
|
||||
.then(data => {
|
||||
state.data = data;
|
||||
doRender();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('KimaiHeatmap: failed to load filtered data', err);
|
||||
});
|
||||
});
|
||||
|
||||
filterDiv.appendChild(selectEl);
|
||||
wrapper.appendChild(filterDiv);
|
||||
}
|
||||
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Re-render on window resize (debounced)
|
||||
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (state.data) doRender();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Initial data fetch
|
||||
fetch(baseUrl)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<HeatmapData>;
|
||||
})
|
||||
.then(data => {
|
||||
state.data = data;
|
||||
doRender();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('KimaiHeatmap: failed to load data', err);
|
||||
svgArea.textContent = 'Failed to load heatmap data';
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
CRITICAL: The variable name for the select element MUST be `selectEl` (not `select`) to avoid shadowing the d3 `select` import. However, since d3 `select` is no longer imported in heatmap.ts (it moved to year.ts), `select` as a variable name would work. But use `selectEl` for clarity and to avoid confusion.
|
||||
|
||||
CRITICAL: The `activeProjectId` closure variable from v1.0 is replaced by `state.filters.projectId`. The onCellClick handler reads from state.filters.projectId instead of a separate closure var.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run build && npm run build:dev</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- assets/src/renderers/year.ts contains `export class YearModeRenderer implements ModeRenderer`
|
||||
- assets/src/renderers/year.ts contains `readonly mode = 'year'`
|
||||
- assets/src/renderers/year.ts contains `import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip'`
|
||||
- assets/src/renderers/year.ts contains `import { buildColorScale } from '../shared/color-scale'`
|
||||
- assets/src/renderers/year.ts contains `import { buildDateMap, generateCells, getWeekInterval` (from shared/date-utils)
|
||||
- assets/src/renderers/types.ts contains `emptyMessage?: string`
|
||||
- assets/src/heatmap.ts does NOT contain `function renderHeatmap`
|
||||
- assets/src/heatmap.ts does NOT contain `function buildDateMap`
|
||||
- assets/src/heatmap.ts does NOT contain `function calculateStreak`
|
||||
- assets/src/heatmap.ts does NOT contain `FALLBACK_COLORS`
|
||||
- assets/src/heatmap.ts contains `import { createInitialState } from './state'`
|
||||
- assets/src/heatmap.ts contains `import { getRenderer, registerRenderer } from './renderers/registry'`
|
||||
- assets/src/heatmap.ts contains `import { YearModeRenderer } from './renderers/year'`
|
||||
- assets/src/heatmap.ts contains `registerRenderer(new YearModeRenderer())`
|
||||
- assets/src/heatmap.ts contains `export function init`
|
||||
- assets/src/heatmap.ts contains `getRenderer(state.mode)`
|
||||
- `npm run build` exits 0 (IIFE bundle builds successfully)
|
||||
- Resources/public/heatmap.js contains `KimaiHeatmap` (IIFE global intact)
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
YearModeRenderer created. heatmap.ts rewritten as slim orchestrator. esbuild produces valid IIFE bundle with KimaiHeatmap.init intact.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Migrate test imports and verify full suite</name>
|
||||
<files>
|
||||
assets/test/heatmap.test.ts,
|
||||
assets/test/stats.test.ts,
|
||||
assets/test/interaction.test.ts,
|
||||
assets/test/filter.test.ts
|
||||
</files>
|
||||
<read_first>
|
||||
assets/test/heatmap.test.ts,
|
||||
assets/test/stats.test.ts,
|
||||
assets/test/interaction.test.ts,
|
||||
assets/test/filter.test.ts,
|
||||
assets/src/heatmap.ts,
|
||||
assets/src/renderers/year.ts,
|
||||
assets/src/shared/stats.ts,
|
||||
assets/src/shared/date-utils.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Update all test file imports to point to new module locations. The tests themselves should NOT change logic -- only import paths and how renderHeatmap is invoked.
|
||||
|
||||
**assets/test/heatmap.test.ts:**
|
||||
- OLD: `import { renderHeatmap } from '../src/heatmap'`
|
||||
- The `renderHeatmap` function no longer exists. Rewrite this test to use YearModeRenderer directly.
|
||||
- New imports:
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { YearModeRenderer } from '../src/renderers/year';
|
||||
import type { RenderContext } from '../src/renderers/types';
|
||||
import type { HeatmapData } from '../src/types';
|
||||
import { createInitialState } from '../src/state';
|
||||
```
|
||||
- Add a `renderer` variable: `let renderer: YearModeRenderer;` initialized in beforeEach as `renderer = new YearModeRenderer();`
|
||||
- Add afterEach to clean up: `renderer.destroy(); document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());`
|
||||
- Build RenderContext helper:
|
||||
```typescript
|
||||
const DEFAULT_CONFIG = { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 };
|
||||
function makeCtx(data: HeatmapData, overrides?: Partial<RenderContext>): RenderContext {
|
||||
return {
|
||||
container,
|
||||
data,
|
||||
state: createInitialState('monday'),
|
||||
config: DEFAULT_CONFIG,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
```
|
||||
- Replace all `renderHeatmap(container, data)` with `renderer.render(makeCtx(data))`
|
||||
- Replace `renderHeatmap(container, data, undefined, undefined, undefined, 'sunday')` with `renderer.render(makeCtx(data, { state: createInitialState('sunday') }))`
|
||||
- Replace `renderHeatmap(container, data, undefined, onClick)` with `renderer.render(makeCtx(data, { onCellClick: onClick }))`
|
||||
- All test assertions remain identical -- same SVG structure, same classes, same tooltip behavior
|
||||
|
||||
**assets/test/stats.test.ts:**
|
||||
- OLD: `import { calculateStreak, calculateStats } from '../src/heatmap'`
|
||||
- NEW: `import { calculateStreak, calculateStats } from '../src/shared/stats'`
|
||||
- No other changes needed. All test logic stays the same.
|
||||
|
||||
**assets/test/interaction.test.ts:**
|
||||
- OLD: `import { renderHeatmap, init } from '../src/heatmap'`
|
||||
- The `renderHeatmap` import must change. Split into two import statements:
|
||||
```typescript
|
||||
import { init } from '../src/heatmap';
|
||||
import { YearModeRenderer } from '../src/renderers/year';
|
||||
import { createInitialState } from '../src/state';
|
||||
import type { RenderContext } from '../src/renderers/types';
|
||||
```
|
||||
- For the `describe('click navigation')` block (tests that used renderHeatmap directly):
|
||||
- Add `let renderer: YearModeRenderer;` and initialize in beforeEach
|
||||
- Add cleanup in afterEach: `renderer.destroy(); document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());`
|
||||
- Replace `renderHeatmap(container, MOCK_DATA, undefined, onClick)` with `renderer.render({ container, data: MOCK_DATA, state: createInitialState('monday'), config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 }, onCellClick: onClick })`
|
||||
- Replace `renderHeatmap(container, MOCK_DATA)` with `renderer.render({ container, data: MOCK_DATA, state: createInitialState('monday'), config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 } })`
|
||||
- The `describe('init click navigation')` block stays unchanged -- still uses `init` from heatmap.ts.
|
||||
|
||||
**assets/test/filter.test.ts:**
|
||||
- OLD: `import { init } from '../src/heatmap'`
|
||||
- This import is UNCHANGED -- `init` is still exported from heatmap.ts. No changes needed to this file.
|
||||
|
||||
After updating imports, run full test suite. All 4 existing test files + 5 new test files from Plan 01 must pass.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- assets/test/heatmap.test.ts does NOT contain `import { renderHeatmap } from '../src/heatmap'`
|
||||
- assets/test/heatmap.test.ts contains `import { YearModeRenderer } from '../src/renderers/year'`
|
||||
- assets/test/heatmap.test.ts contains `import { createInitialState } from '../src/state'`
|
||||
- assets/test/stats.test.ts contains `import { calculateStreak, calculateStats } from '../src/shared/stats'`
|
||||
- assets/test/stats.test.ts does NOT contain `from '../src/heatmap'`
|
||||
- assets/test/interaction.test.ts contains `import { YearModeRenderer }`
|
||||
- assets/test/interaction.test.ts does NOT contain `import { renderHeatmap`
|
||||
- assets/test/interaction.test.ts contains `import { init } from '../src/heatmap'`
|
||||
- `npx vitest run` exits 0 with all test files passing
|
||||
- `npm run build` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
All test imports migrated to new module locations. Full test suite (9 files) passes. esbuild produces valid bundle.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Visual regression check of rendered heatmap</name>
|
||||
<files>Resources/public/heatmap.js</files>
|
||||
<action>
|
||||
Build the dev bundle and verify in a running Kimai instance that the heatmap renders identically to v1.0.
|
||||
|
||||
Steps for the human verifier:
|
||||
1. Run `npm run build:dev` to build with sourcemap
|
||||
2. Start local Kimai dev environment
|
||||
3. Navigate to the dashboard
|
||||
4. Verify the heatmap renders identically to before:
|
||||
- Cells are colored with green scale (4 shades: #9be9a8, #40c463, #30a14e, #216e39)
|
||||
- Hover shows tooltip with date, hours, entries
|
||||
- Click navigates to timesheet filtered by date
|
||||
- Stats row shows streak, total, avg, busiest
|
||||
- Filter dropdown works (if projects exist)
|
||||
- Window resize re-renders correctly
|
||||
5. Open browser console -- no errors should appear
|
||||
6. Check `typeof KimaiHeatmap.init` in console -- should be 'function'
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run build:dev && echo "Build successful -- manual visual check required"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Human confirms heatmap renders identically to v1.0. No visual regressions. No console errors. KimaiHeatmap.init works.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> Plugin JS | Unchanged from v1.0. init() reads data-* attributes and fetches from API. |
|
||||
| API response -> Renderer | JSON data rendered as SVG. Tooltip uses innerHTML with API-sourced data. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-03 | T (Tampering) | renderers/year.ts tooltip innerHTML | accept | Pre-existing v1.0 pattern. Data comes from own backend API (trusted). No user-controlled input reaches innerHTML beyond what the Kimai backend already sanitizes. Addressing XSS in tooltip is a separate concern, not introduced by this refactor. |
|
||||
| T-06-04 | S (Spoofing) | heatmap.ts init() data-url attribute | accept | Pre-existing v1.0 pattern. data-url is set server-side in Twig template. Attacker would need to modify server response to change it. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx vitest run` -- all 9 test files pass (4 existing migrated + 5 new from Plan 01)
|
||||
2. `npm run build` -- IIFE bundle builds without errors
|
||||
3. `npm run build:dev` -- sourcemap build works
|
||||
4. Browser: KimaiHeatmap.init renders year heatmap identically to v1.0
|
||||
5. Browser console: no errors on load, hover, click, filter, resize
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- YearModeRenderer exists and implements ModeRenderer interface
|
||||
- heatmap.ts is a slim orchestrator (~80-120 lines, down from 413)
|
||||
- renderHeatmap() no longer exists as a standalone function
|
||||
- All test files import from new module locations
|
||||
- Full test suite passes (9 files)
|
||||
- esbuild produces valid IIFE bundle
|
||||
- Visual regression check passes (human verification)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-renderer-architecture/06-02-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue