kimai-plugin-heatmap/.planning/phases/07-mode-switcher-week-mode/07-02-PLAN.md

329 lines
15 KiB
Markdown

---
phase: 07-mode-switcher-week-mode
plan: 02
type: execute
wave: 2
depends_on: [07-01]
files_modified:
- assets/src/renderers/week.ts
- assets/src/heatmap.ts
- assets/test/week.test.ts
autonomous: false
requirements: [VIZ-02, TEST-01]
must_haves:
truths:
- "Switching to week mode renders 7 horizontal cells colored by aggregated metric"
- "Day labels (Mon-Sun or Sun-Sat) respect user's start-of-week preference"
- "Tooltip on week cell shows full weekday name + aggregated value"
- "Weekdays with zero tracked time render as empty cells (not lowest green)"
- "Switching back to year mode restores the full calendar heatmap"
artifacts:
- path: "assets/src/renderers/week.ts"
provides: "WeekModeRenderer implementing ModeRenderer"
exports: ["WeekModeRenderer"]
min_lines: 60
- path: "assets/test/week.test.ts"
provides: "Tests for week renderer aggregation and rendering"
min_lines: 50
key_links:
- from: "assets/src/heatmap.ts"
to: "assets/src/renderers/week.ts"
via: "import + registerRenderer"
pattern: "registerRenderer.*WeekModeRenderer"
- from: "assets/src/renderers/week.ts"
to: "assets/src/shared/color-scale.ts"
via: "buildColorScale for week cell fill"
pattern: "buildColorScale"
- from: "assets/src/renderers/week.ts"
to: "assets/src/shared/tooltip.ts"
via: "createTooltip/showTooltip/hideTooltip"
pattern: "createTooltip"
---
<objective>
Implement the WeekModeRenderer that aggregates DayEntry data by weekday and renders a 7-cell horizontal heatmap, then wire it into the orchestrator.
Purpose: Delivers the first new visualization mode -- users can see which weekdays are busiest at a glance.
Output: Working week-mode renderer with tooltip, registered and dispatch-ready, with tests.
</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/07-mode-switcher-week-mode/07-CONTEXT.md
@.planning/phases/07-mode-switcher-week-mode/07-RESEARCH.md
@.planning/phases/07-mode-switcher-week-mode/07-UI-SPEC.md
@.planning/phases/07-mode-switcher-week-mode/07-01-SUMMARY.md
<interfaces>
<!-- Key types and contracts the executor needs. -->
From assets/src/renderers/types.ts:
```typescript
export interface RenderContext {
container: HTMLElement;
data: HeatmapData;
state: HeatmapState;
config: HeatmapConfig;
onCellClick?: (dateStr: string) => void;
emptyMessage?: string;
}
export interface ModeRenderer {
readonly mode: string;
render(ctx: RenderContext): void;
destroy?(): void;
}
```
From assets/src/types.ts:
```typescript
export interface DayEntry {
date: string; // "YYYY-MM-DD"
hours: number;
count: number;
}
export type DisplayMetric = 'hours' | 'count';
```
From assets/src/state.ts:
```typescript
export interface HeatmapState {
mode: HeatmapMode;
metric: DisplayMetric;
filters: FilterState;
weekStart: string;
data: HeatmapData | null;
}
```
From assets/src/shared/color-scale.ts:
```typescript
export function buildColorScale(days: DayEntry[], metric?: DisplayMetric): ScaleQuantize<string>;
```
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/date-utils.ts:
```typescript
export function getDayLabels(weekStart: string): string[];
// DAY_LABELS_MONDAY = ['Mon', '', 'Wed', '', 'Fri', '', '']
// DAY_LABELS_SUNDAY = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat']
```
From assets/src/renderers/registry.ts:
```typescript
export function registerRenderer(renderer: ModeRenderer): void;
```
Existing registration pattern in heatmap.ts:
```typescript
import { YearModeRenderer } from './renderers/year';
registerRenderer(new YearModeRenderer());
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Implement WeekModeRenderer and register in orchestrator</name>
<files>
assets/src/renderers/week.ts,
assets/src/heatmap.ts,
assets/test/week.test.ts
</files>
<read_first>
assets/src/renderers/year.ts,
assets/src/renderers/types.ts,
assets/src/renderers/registry.ts,
assets/src/shared/color-scale.ts,
assets/src/shared/tooltip.ts,
assets/src/shared/date-utils.ts,
assets/src/heatmap.ts,
assets/src/types.ts,
assets/src/state.ts,
.planning/phases/07-mode-switcher-week-mode/07-UI-SPEC.md
</read_first>
<behavior>
- WeekModeRenderer.mode equals 'week'
- render() with 3 DayEntries (Mon, Mon, Wed) aggregates: Monday totalHours = sum of both Mon entries, Wednesday totalHours = Wed entry hours
- render() produces an SVG with exactly 7 rect elements
- Rect for a weekday with data has a fill color from buildColorScale (not empty fill)
- Rect for a weekday with no data has class 'heatmap-empty'
- Labels respect weekStart: Monday-start first label is 'Mon', Sunday-start first label is 'Sun'
- Tooltip on hover shows full weekday name + aggregated value (e.g. "Monday: 8.5h (4 entries)")
- Tooltip for count metric shows "Monday: 4 entries (8.5h)" (count first)
- Empty weekday tooltip shows "Tuesday: No tracked time"
- destroy() removes tooltip element
</behavior>
<action>
**1. Create `assets/src/renderers/week.ts`:**
Define a local `WeekdayAggregate` interface:
```typescript
interface WeekdayAggregate {
dayIndex: number; // 0-6, relative to weekStart
label: string; // Full name: "Monday", "Tuesday", etc.
shortLabel: string; // Short: "Mon", "Tue", etc.
totalHours: number;
totalCount: number;
dayCount: number; // number of distinct days with entries
}
```
Weekday name arrays (for tooltip -- full names per D-07):
```typescript
const WEEKDAY_NAMES_MONDAY = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const WEEKDAY_NAMES_SUNDAY = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const WEEKDAY_SHORT_MONDAY = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const WEEKDAY_SHORT_SUNDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
```
`aggregateByWeekday(days: DayEntry[], weekStart: string): WeekdayAggregate[]`:
- Create 7 buckets, indexed 0-6
- For each day entry: parse date, get JS day (`new Date(d.date + 'T00:00:00').getDay()`), compute index using `weekStart === 'sunday' ? jsDay : (jsDay + 6) % 7`
- Accumulate totalHours, totalCount, dayCount into bucket
- Set label from WEEKDAY_NAMES array by index, shortLabel from WEEKDAY_SHORT array
- Return all 7 buckets
`export class WeekModeRenderer implements ModeRenderer`:
- `readonly mode = 'week'`
- Private `tooltip: HTMLDivElement | null = null`
`render(ctx: RenderContext)`:
- Clear container: `ctx.container.innerHTML = ''`
- Handle empty data (same pattern as YearModeRenderer -- show emptyMessage or "No tracking data available")
- Destroy previous tooltip, create new one via `createTooltip()`
- Call `aggregateByWeekday(ctx.data.days, ctx.state.weekStart)`
- Build color scale: only pass DayEntry items that have data to `buildColorScale()` so scale domain reflects actual values. Create synthetic DayEntry array from aggregates for scale: `aggregates.filter(a => a.dayCount > 0).map(a => ({ date: '', hours: a.totalHours, count: a.totalCount }))`
- Then call `buildColorScale(syntheticDays, ctx.state.metric)`
- SVG layout constants (per UI-SPEC): cellWidth=60, cellHeight=40, cellGap=4, labelWidth=50
- SVG dimensions: width = `labelWidth + 7 * (cellWidth + cellGap)`, height = cellHeight
- Create SVG via d3 `select(ctx.container).append('svg')` with computed dimensions, class `heatmap-svg`
- Render 7 labels as `<text>` elements: class `heatmap-label`, x=0, y = vertically centered in cell (`cellHeight / 2 + 3`), text = shortLabel for each aggregate. Show ALL 7 labels (not the sparse pattern from year-mode)
- Render 7 rects: x = `labelWidth + i * (cellWidth + cellGap)`, y = 0, width = cellWidth, height = cellHeight
- Class: `heatmap-week-cell` always, add `heatmap-empty` if `dayCount === 0`
- Fill: if `dayCount === 0`, return `''` (CSS handles empty fill). Otherwise, use `colorScale(ctx.state.metric === 'hours' ? agg.totalHours : agg.totalCount)`
- rx="2" ry="2" for rounded corners
- Tooltip on mouseenter: if `dayCount > 0`:
- hours metric: `"<strong>{label}</strong><br>{totalHours.toFixed(1)}h ({totalCount} entries)"`
- count metric: `"<strong>{label}</strong><br>{totalCount} entries ({totalHours.toFixed(1)}h)"`
- If `dayCount === 0`: `"<strong>{label}</strong><br>No tracked time"`
- Use `showTooltip(tooltip, html, rect.getBoundingClientRect(), cellWidth)`
- On mouseleave: `hideTooltip(tooltip)`
- No click handler on week cells (per discretion -- aggregation has no single-date target)
`destroy()`:
- Remove tooltip if exists, set to null
Export the class.
**2. Modify `assets/src/heatmap.ts`:**
- Add import: `import { WeekModeRenderer } from './renderers/week';`
- Add registration below existing: `registerRenderer(new WeekModeRenderer());`
- That's the only change needed in heatmap.ts for this task (controls wiring done in Plan 01)
**3. Create `assets/test/week.test.ts`:**
Test the WeekModeRenderer using the pattern from existing test files. Import the renderer, create a container div, build test DayEntry arrays with known dates and values. Verify:
- SVG contains exactly 7 rect elements
- Aggregation sums correctly (two entries on same weekday -> combined hours)
- Empty weekdays get `heatmap-empty` class
- Data weekdays get a fill color (not empty string)
- Labels appear in correct weekStart order
- destroy() removes tooltip
- Test with weekStart='sunday' to verify label order changes
</action>
<verify>
<automated>cd /home/toph/code/toph/kimai-heatmap && npx vitest run assets/test/week.test.ts && npm test</automated>
</verify>
<acceptance_criteria>
- assets/src/renderers/week.ts contains `export class WeekModeRenderer implements ModeRenderer`
- assets/src/renderers/week.ts contains `readonly mode = 'week'`
- assets/src/renderers/week.ts contains `aggregateByWeekday`
- assets/src/renderers/week.ts contains `buildColorScale`
- assets/src/renderers/week.ts contains `createTooltip`
- assets/src/renderers/week.ts contains `showTooltip` and `hideTooltip`
- assets/src/renderers/week.ts contains `No tracked time`
- assets/src/renderers/week.ts contains `heatmap-week-cell`
- assets/src/renderers/week.ts contains `heatmap-empty`
- assets/src/renderers/week.ts contains `(jsDay + 6) % 7` (Monday-start weekday index)
- assets/src/heatmap.ts contains `import { WeekModeRenderer } from './renderers/week'`
- assets/src/heatmap.ts contains `registerRenderer(new WeekModeRenderer())`
- assets/test/week.test.ts exists and `npx vitest run assets/test/week.test.ts` exits 0
- `npm test` exits 0 (all tests pass including new week tests)
- `npm run build:dev` exits 0 (bundle builds)
</acceptance_criteria>
<done>WeekModeRenderer aggregates DayEntry data by weekday, renders 7 horizontal SVG cells with color scale and tooltips, respects weekStart preference, and is registered for dispatch. All tests pass.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Visual verification of mode switcher and week mode</name>
<files>none</files>
<action>Human visual verification of the complete Phase 7 feature set in a running Kimai instance. No code changes -- verify that Plan 01 (controls) and Plan 02 Task 1 (week renderer) work correctly together end-to-end.</action>
<verify>
<automated>cd /home/toph/code/toph/kimai-heatmap && npm test && npm run build:dev</automated>
</verify>
<done>Human confirms: mode switcher toggles year/week views, metric toggle switches hours/count coloring, week-mode shows 7 cells with correct tooltips, stats row hidden in week mode, no console errors.</done>
<what-built>
Mode switcher (Year/Week) and metric toggle (Hours/Count) controls in the widget header. Week-mode renderer showing 7 weekday cells with aggregated data. Stats row hidden in week mode, visible in year mode.
</what-built>
<how-to-verify>
1. Start local Kimai dev environment, navigate to dashboard
2. Verify the heatmap widget shows two controls in the header: "Year | Week" and "Hours | Count"
3. Click "Week" -- should show 7 horizontal cells with weekday labels (Mon-Sun), stats row should disappear
4. Hover over a colored cell -- tooltip shows full weekday name + aggregated hours and entry count
5. Hover over an empty cell -- tooltip shows "No tracked time"
6. Click "Count" -- cell colors should change (re-colored by entry count instead of hours)
7. Click "Year" -- should return to full calendar heatmap, stats row reappears
8. Click "Count" in year mode -- year cells re-color by count
9. Change project filter -- both modes should reflect filtered data
10. Check browser console -- no JavaScript errors
</how-to-verify>
<resume-signal>Type "approved" or describe issues</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
No new trust boundaries. Week renderer consumes the same authenticated data as year renderer. No new endpoints, inputs, or data flows.
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-02 | T (Tampering) | weekday aggregation | accept | Client-side aggregation of already-authenticated data; no security impact |
</threat_model>
<verification>
- `npx vitest run assets/test/week.test.ts` passes
- `npm test` passes (full suite)
- `npm run build:dev` succeeds
- Human verifies visual output in running Kimai
</verification>
<success_criteria>
- Week-mode renders 7 horizontal cells colored by aggregated hours or count
- Day labels respect start-of-week preference
- Tooltips show weekday name + value in correct format for hours/count metrics
- Empty weekdays render as empty cells (not lowest green)
- Mode switching between year and week works without errors
- Metric toggle affects both modes
</success_criteria>
<output>
After completion, create `.planning/phases/07-mode-switcher-week-mode/07-02-SUMMARY.md`
</output>