329 lines
15 KiB
Markdown
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>
|