264 lines
10 KiB
Markdown
264 lines
10 KiB
Markdown
---
|
|
phase: 07-mode-switcher-week-mode
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- Resources/views/widget/heatmap.html.twig
|
|
- assets/src/ui/controls.ts
|
|
- assets/src/heatmap.ts
|
|
- Resources/public/heatmap.css
|
|
- assets/test/controls.test.ts
|
|
autonomous: true
|
|
requirements: [VIZ-01, VIZ-05, TEST-01]
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
artifacts:
|
|
- path: "assets/src/ui/controls.ts"
|
|
provides: "createModeControl and createMetricControl functions"
|
|
exports: ["createModeControl", "createMetricControl"]
|
|
- path: "Resources/views/widget/heatmap.html.twig"
|
|
provides: "Controls placeholder div in card header"
|
|
contains: "heatmap-controls"
|
|
- path: "assets/test/controls.test.ts"
|
|
provides: "Tests for mode switcher and metric toggle"
|
|
min_lines: 40
|
|
key_links:
|
|
- from: "assets/src/heatmap.ts"
|
|
to: "assets/src/ui/controls.ts"
|
|
via: "import createModeControl, createMetricControl"
|
|
pattern: "import.*controls"
|
|
- from: "assets/src/ui/controls.ts"
|
|
to: "state.mode / state.metric"
|
|
via: "onChange callbacks"
|
|
pattern: "onChange"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</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/06-renderer-architecture/06-01-SUMMARY.md
|
|
@.planning/phases/06-renderer-architecture/06-02-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs. -->
|
|
|
|
From assets/src/types.ts:
|
|
```typescript
|
|
export type DisplayMetric = 'hours' | 'count';
|
|
export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
|
|
```
|
|
|
|
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 getRenderer(mode: string): ModeRenderer;
|
|
```
|
|
|
|
From assets/src/shared/stats.ts:
|
|
```typescript
|
|
export function renderStats(container: HTMLElement, days: DayEntry[]): void;
|
|
```
|
|
|
|
From assets/src/heatmap.ts (current doRender pattern):
|
|
```typescript
|
|
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;
|
|
};
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Create UI controls module and wire into heatmap orchestrator</name>
|
|
<files>
|
|
assets/src/ui/controls.ts,
|
|
assets/src/heatmap.ts,
|
|
Resources/views/widget/heatmap.html.twig,
|
|
Resources/public/heatmap.css,
|
|
assets/test/controls.test.ts
|
|
</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<behavior>
|
|
- 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
|
|
</behavior>
|
|
<action>
|
|
**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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/toph/code/toph/kimai-heatmap && npx vitest run assets/test/controls.test.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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)
|
|
</acceptance_criteria>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/07-mode-switcher-week-mode/07-01-SUMMARY.md`
|
|
</output>
|