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

10 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
07-mode-switcher-week-mode 01 execute 1
Resources/views/widget/heatmap.html.twig
assets/src/ui/controls.ts
assets/src/heatmap.ts
Resources/public/heatmap.css
assets/test/controls.test.ts
true
VIZ-01
VIZ-05
TEST-01
truths artifacts key_links
A segmented control in the widget header shows Year and Week buttons
Clicking Week sets state.mode to week and triggers doRender
A separate compact segmented control shows Hours and Count buttons
Clicking Count sets state.metric to count and triggers doRender
Controls persist across re-renders (live in card header, not SVG area)
Stats row is hidden when mode is week, shown when mode is year
path provides exports
assets/src/ui/controls.ts createModeControl and createMetricControl functions
createModeControl
createMetricControl
path provides contains
Resources/views/widget/heatmap.html.twig Controls placeholder div in card header heatmap-controls
path provides min_lines
assets/test/controls.test.ts Tests for mode switcher and metric toggle 40
from to via pattern
assets/src/heatmap.ts assets/src/ui/controls.ts import createModeControl, createMetricControl import.*controls
from to via pattern
assets/src/ui/controls.ts state.mode / state.metric onChange callbacks onChange
Build the mode switcher and hours/count toggle UI controls, wire them into the heatmap orchestrator, and add the Twig template placeholder.

Purpose: Enables switching between year/week modes and hours/count metrics -- the UI backbone for all v1.1 visualization features. Output: Two Tabler nav-segmented controls in the widget header, wired to HeatmapState, with tests.

<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>

@.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/06-renderer-architecture/06-01-SUMMARY.md @.planning/phases/06-renderer-architecture/06-02-SUMMARY.md

From assets/src/types.ts:

export type DisplayMetric = 'hours' | 'count';
export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';

From assets/src/state.ts:

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:

export function getRenderer(mode: string): ModeRenderer;

From assets/src/shared/stats.ts:

export function renderStats(container: HTMLElement, days: DayEntry[]): void;

From assets/src/heatmap.ts (current doRender pattern):

const doRender = () => {
  if (!state.data) return;
  const renderer = getRenderer(state.mode);
  renderer.destroy?.();
  renderer.render({ container: svgArea, data: state.data, state, config: {...}, onCellClick, emptyMessage: ... });
  renderStats(container, state.data.days);
  svgArea.scrollLeft = svgArea.scrollWidth;
};
Task 1: Create UI controls module and wire into heatmap orchestrator assets/src/ui/controls.ts, assets/src/heatmap.ts, Resources/views/widget/heatmap.html.twig, Resources/public/heatmap.css, assets/test/controls.test.ts assets/src/heatmap.ts, assets/src/types.ts, assets/src/state.ts, assets/src/shared/stats.ts, Resources/views/widget/heatmap.html.twig, Resources/public/heatmap.css, .planning/phases/07-mode-switcher-week-mode/07-UI-SPEC.md - createModeControl('year', [{key:'year',label:'Year'},{key:'week',label:'Week'}], cb) returns nav element with class 'nav nav-segmented' and role='tablist' - createModeControl: first button has class 'nav-link active', second has 'nav-link' (no active) - createModeControl: clicking inactive button adds 'active' to it, removes 'active' from previous, calls onChange with key - createMetricControl('hours', cb) returns nav element with class 'nav nav-segmented nav-sm' - createMetricControl: clicking 'Count' calls onChange with 'count' - Both controls have role='tab' on buttons and aria-selected attribute **1. Create `assets/src/ui/controls.ts`** with two exported functions:
`createModeControl(activeMode: string, modes: Array<{key: string; label: string}>, onChange: (mode: string) => void): HTMLElement`
- Create `<nav>` with `className = 'nav nav-segmented'` and `role="tablist"`
- For each mode, create `<button>` with `className = 'nav-link'` (add ` active` if `m.key === activeMode`)
- Set `role="tab"`, `aria-selected` = `"true"/"false"` based on active
- On click: remove `active` from all siblings, add `active` to clicked, update `aria-selected`, call `onChange(m.key)`
- Return the nav element

`createMetricControl(activeMetric: string, onChange: (metric: string) => void): HTMLElement`
- Same pattern but `className = 'nav nav-segmented nav-sm'`
- Two hardcoded options: `{key: 'hours', label: 'Hours'}` and `{key: 'count', label: 'Count'}`

**2. Modify `Resources/views/widget/heatmap.html.twig`** -- change the `{% block box_title %}` block from:
```
{% block box_title %}
    {{ title }}
{% endblock %}
```
To:
```
{% block box_title %}
    <div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
        {{ title }}
        <div id="heatmap-controls" style="display: flex; gap: 8px; margin-left: auto;"></div>
    </div>
{% endblock %}
```

**3. Modify `assets/src/heatmap.ts`** -- in the `init()` function:
- Add import: `import { createModeControl, createMetricControl } from './ui/controls';`
- After building wrapper and svgArea, before the filter dropdown section, add control wiring:
```typescript
const controlsContainer = document.getElementById('heatmap-controls');
if (controlsContainer) {
  const modeControl = createModeControl(state.mode, [
    { key: 'year', label: 'Year' },
    { key: 'week', label: 'Week' },
  ], (mode) => {
    state.mode = mode as HeatmapMode;
    doRender();
  });

  const metricControl = createMetricControl(state.metric, (metric) => {
    state.metric = metric as DisplayMetric;
    doRender();
  });

  controlsContainer.appendChild(modeControl);
  controlsContainer.appendChild(metricControl);
}
```
- Modify `doRender()` to conditionally render stats: replace `renderStats(container, state.data.days);` with:
```typescript
if (state.mode === 'year') {
  renderStats(container, state.data.days);
} else {
  const existingStats = container.querySelector('.heatmap-stats');
  if (existingStats) existingStats.remove();
}
```
- Modify `doRender()` to only auto-scroll for year mode: wrap `svgArea.scrollLeft = svgArea.scrollWidth;` in `if (state.mode === 'year') { ... }`

**4. Add to `Resources/public/heatmap.css`:**
```css
.heatmap-week-cell {
  cursor: default;
}
```

**5. Create `assets/test/controls.test.ts`** with tests per the behavior block above. Import from `../src/ui/controls`. Use jsdom to verify DOM output, class names, click handlers, and aria attributes.
cd /home/toph/code/toph/kimai-heatmap && npx vitest run assets/test/controls.test.ts - assets/src/ui/controls.ts contains `export function createModeControl(` - assets/src/ui/controls.ts contains `export function createMetricControl(` - assets/src/ui/controls.ts contains `nav nav-segmented` - assets/src/ui/controls.ts contains `nav-sm` - assets/src/ui/controls.ts contains `role` and `tablist` and `aria-selected` - assets/src/heatmap.ts contains `import { createModeControl, createMetricControl } from './ui/controls'` - assets/src/heatmap.ts contains `document.getElementById('heatmap-controls')` - assets/src/heatmap.ts contains `state.mode === 'year'` (conditional stats) - Resources/views/widget/heatmap.html.twig contains `id="heatmap-controls"` - Resources/views/widget/heatmap.html.twig contains `display: flex; align-items: center; gap: 12px` - Resources/public/heatmap.css contains `.heatmap-week-cell` - assets/test/controls.test.ts exists and `npx vitest run assets/test/controls.test.ts` exits 0 - `npm test` exits 0 (all 62+ tests pass) Mode switcher and metric toggle controls render with correct Tabler classes, respond to clicks by updating active state and calling callbacks, and are wired into heatmap.ts to update state and trigger doRender. Stats row hidden in non-year modes. All tests pass.

<threat_model>

Trust Boundaries

No new trust boundaries. Controls are purely client-side UI manipulating existing client state. No new data flows, authentication, or input from users beyond button clicks.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07-01 T (Tampering) mode/metric state accept Client-side only; tampering has no security impact (changes own view)
</threat_model>
- `npx vitest run assets/test/controls.test.ts` passes - `npm test` passes (all existing + new tests) - `npm run build:dev` succeeds - Twig template contains `#heatmap-controls` div

<success_criteria>

  • Mode switcher with Year/Week buttons renders in widget header using Tabler nav-segmented
  • Metric toggle with Hours/Count buttons renders adjacent to mode switcher using nav-segmented nav-sm
  • Clicking controls updates HeatmapState and calls doRender
  • Stats row conditionally hidden in week mode
  • All tests green </success_criteria>
After completion, create `.planning/phases/07-mode-switcher-week-mode/07-01-SUMMARY.md`