10 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-mode-switcher-week-mode | 01 | execute | 1 |
|
true |
|
|
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.mdFrom 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> |
<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>