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

18 KiB

Phase 7: Mode Switcher + Week Mode - Research

Researched: 2026-04-09 Domain: d3.js visualization modes, Tabler UI segmented controls, client-side data aggregation Confidence: HIGH

Summary

Phase 7 adds two UI controls (mode switcher and hours/count toggle) and one new renderer (WeekModeRenderer) to the existing strategy-pattern architecture from Phase 6. The infrastructure is solid -- ModeRenderer interface, renderer registry, HeatmapState with mode and metric fields, and shared utilities (color scale, tooltip, date-utils) are all in place. The new work is: (1) render two Tabler nav-segmented controls in the widget header, (2) wire their click handlers to update state.mode and state.metric then call doRender(), and (3) implement WeekModeRenderer that aggregates DayEntry[] by weekday and renders 7 colored cells.

Tabler provides a native nav-segmented component that matches the "segmented control" design from CONTEXT.md decisions. No custom CSS needed for the control itself -- just Tabler classes. The week-mode renderer is straightforward d3 work: group existing data by day-of-week (0-6), sum hours/count per weekday, render 7 rectangles with the shared buildColorScale(). The getDayLabels() and weekday-index calculation in date-utils.ts already handle start-of-week preference.

Primary recommendation: Build controls in JS (not Twig) since they interact with HeatmapState; keep the Twig template unchanged. Register WeekModeRenderer alongside YearModeRenderer at module level.

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Segmented control in widget header row (Kimai Tabler card header area), next to title
  • D-02: "Year" and "Week" as the two mode options (Day/Combined in Phase 9)
  • D-03: Active mode highlighted using Kimai/Tabler CSS conventions
  • D-04: Week-mode horizontal layout -- 7 cells, one per weekday, colored by aggregated metric
  • D-05: Day labels alongside cells, respecting user's start-of-week from state.weekStart
  • D-06: Client-side aggregation of existing DayEntry[] grouped by weekday (no backend changes)
  • D-07: Tooltip on hover: weekday name + aggregated value
  • D-08: Hours/Count toggle is a separate small segmented control
  • D-09: Placed adjacent to mode switcher in header
  • D-10: Toggles state.metric between 'hours' and 'count', triggers re-render without re-fetch
  • D-11: Affects color scale in both year and week modes

Claude's Discretion

  • Exact sizing/spacing of week-mode cells
  • Whether week-mode cells are clickable (and what click-through shows)
  • Stats row behavior when in week mode
  • Exact Tabler CSS classes for segmented controls
  • Animation/transition when switching modes

Deferred Ideas (OUT OF SCOPE)

None </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
VIZ-01 Mode switcher UI allows toggling between year, week, day, and combined views Tabler nav-segmented component; only Year/Week for this phase per D-02
VIZ-02 Week-mode renders day-of-week aggregation showing busiest weekdays WeekModeRenderer with client-side DayEntry grouping by weekday
VIZ-05 Hours vs entry-count toggle switches color scale metric across all modes Separate nav-segmented control toggling state.metric; buildColorScale() already metric-aware
TEST-01 Vitest tests for mode switcher, each renderer, and display toggle Test patterns established in existing 9 test files; jsdom environment
</phase_requirements>

Standard Stack

Core (already installed -- no new packages)

Library Version Purpose Why Standard
d3-selection ^3.0.0 DOM manipulation for week cells Already in use for year renderer
d3-scale ^4.0.2 Color scale for week cells buildColorScale() already wraps this
d3-array ^3.2.4 max() for scale domain Already in shared/color-scale.ts
vitest ^4.1.3 Test runner Already configured with jsdom

No new npm packages required. [VERIFIED: package.json in codebase]

Tabler CSS (bundled with Kimai)

Component Classes Purpose
Segmented control nav nav-segmented, nav-link, active Mode switcher and metric toggle
Small variant nav-sm Compact toggle for hours/count

[CITED: docs.tabler.io/ui/components/segmented-control]

Architecture Patterns

Project Structure (new files only)

assets/
  src/
    renderers/
      week.ts          # NEW: WeekModeRenderer
    ui/
      controls.ts      # NEW: mode switcher + metric toggle DOM builders
  test/
    week.test.ts       # NEW: WeekModeRenderer tests
    controls.test.ts   # NEW: UI control interaction tests

Pattern 1: WeekModeRenderer (Strategy Pattern Extension)

What: New renderer implementing ModeRenderer interface, registered via registerRenderer().

When to use: Rendering the week-mode aggregation view.

Example:

// assets/src/renderers/week.ts
import type { ModeRenderer, RenderContext } from './types';
import { registerRenderer } from './registry';
import { buildColorScale } from '../shared/color-scale';
import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip';

interface WeekdayAggregate {
  dayIndex: number;    // 0-6, relative to weekStart
  label: string;       // "Mon", "Tue", etc.
  totalHours: number;
  totalCount: number;
  dayCount: number;    // number of occurrences for averaging
}

export class WeekModeRenderer implements ModeRenderer {
  readonly mode = 'week';
  private tooltip: HTMLDivElement | null = null;

  render(ctx: RenderContext): void {
    // 1. Aggregate DayEntry[] by weekday
    // 2. Build 7-cell horizontal SVG
    // 3. Color via buildColorScale() using ctx.state.metric
    // 4. Attach tooltip handlers
  }

  destroy(): void {
    this.tooltip?.remove();
    this.tooltip = null;
  }
}

// Module-level registration
registerRenderer(new WeekModeRenderer());

Pattern 2: UI Controls (JS-driven, not Twig)

What: Build mode switcher and metric toggle as DOM elements in heatmap.ts init, inserted into the widget header area.

Why JS not Twig: Controls must interact with HeatmapState and call doRender(). Putting them in Twig would require a separate event-binding pass and Twig has no knowledge of JS state. Building them in JS during init keeps the coupling clean.

Example:

// assets/src/ui/controls.ts
export interface ControlCallbacks {
  onModeChange: (mode: string) => void;
  onMetricChange: (metric: string) => void;
}

export function createModeControl(
  activeMode: string,
  modes: Array<{ key: string; label: string }>,
  onChange: (mode: string) => void,
): HTMLElement {
  const nav = document.createElement('nav');
  nav.className = 'nav nav-segmented';
  nav.setAttribute('role', 'tablist');

  for (const m of modes) {
    const btn = document.createElement('button');
    btn.className = 'nav-link' + (m.key === activeMode ? ' active' : '');
    btn.setAttribute('role', 'tab');
    btn.textContent = m.label;
    btn.addEventListener('click', () => {
      nav.querySelectorAll('.nav-link').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      onChange(m.key);
    });
    nav.appendChild(btn);
  }
  return nav;
}

export function createMetricControl(
  activeMetric: string,
  onChange: (metric: string) => void,
): HTMLElement {
  // Same pattern, nav-sm for compact size
  const nav = document.createElement('nav');
  nav.className = 'nav nav-segmented nav-sm';
  // ... buttons for 'Hours' and 'Count'
  return nav;
}

Pattern 3: Weekday Aggregation (Client-Side)

What: Group DayEntry[] by day-of-week index, summing hours and counts.

Key detail: Must respect state.weekStart. The existing date-utils.ts calculates dayOfWeek as (jsDay + 6) % 7 for Monday start and jsDay for Sunday start. Reuse the same logic.

function aggregateByWeekday(
  days: DayEntry[],
  weekStart: string,
): WeekdayAggregate[] {
  const buckets = new Array(7).fill(null).map((_, i) => ({
    dayIndex: i,
    label: '', // filled from getDayLabels or full-name variant
    totalHours: 0,
    totalCount: 0,
    dayCount: 0,
  }));

  for (const d of days) {
    const jsDay = new Date(d.date + 'T00:00:00').getDay();
    const idx = weekStart === 'sunday' ? jsDay : (jsDay + 6) % 7;
    buckets[idx].totalHours += d.hours;
    buckets[idx].totalCount += d.count;
    buckets[idx].dayCount += 1;
  }
  return buckets;
}

Pattern 4: Control Placement in Widget Header

What: Insert controls into the card header alongside the existing title.

Approach: The Twig template uses @theme/embeds/card.html.twig. The {% block box_title %} currently holds just {{ title }}. Two options:

  1. Modify Twig to add a placeholder <div id="heatmap-controls"> in box_title block -- then JS populates it.
  2. Pure JS -- find the .card-header parent of #heatmap-container and inject controls.

Recommendation: Option 1 -- add a controls placeholder in Twig. This is cleaner because it ensures the layout structure is correct even before JS loads, and avoids fragile DOM traversal.

{% 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 %}

Then in heatmap.ts, build controls and append to #heatmap-controls.

Anti-Patterns to Avoid

  • Merging mode switcher and metric toggle into one control: CONTEXT.md D-08 explicitly says separate controls.
  • Re-fetching data on metric toggle: D-10 says re-render without re-fetch. The data already has both hours and count fields.
  • Building week renderer from scratch without shared utilities: buildColorScale(), tooltip helpers, and getDayLabels() are all reusable.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Segmented control UI Custom toggle buttons with manual styling Tabler nav-segmented classes Native Kimai theme integration, accessible markup
Color scale New scale for week cells buildColorScale() from shared/color-scale.ts Already metric-aware, consistent across modes
Tooltips New tooltip logic createTooltip/showTooltip/hideTooltip from shared/tooltip.ts Consistent behavior, already tested
Weekday ordering Hard-coded day arrays Reuse (jsDay + 6) % 7 pattern from date-utils.ts Already handles Monday/Sunday start

Common Pitfalls

Pitfall 1: Weekday Index Mismatch Between Aggregation and Labels

What goes wrong: Aggregation uses one weekday ordering, labels use another, so Tuesday's data shows on Monday's cell. Why it happens: JS Date.getDay() returns 0=Sunday. The existing code remaps for Monday-start. If aggregation and label generation use different mappings, they desync. How to avoid: Use the exact same index formula as generateCells() in date-utils.ts: weekStart === 'sunday' ? jsDay : (jsDay + 6) % 7. Map labels using the same index. Warning signs: Tests with known weekday data showing wrong labels.

Pitfall 2: Empty Aggregate Buckets Confuse Color Scale

What goes wrong: Weekdays with zero entries get passed to buildColorScale() which sets domain [0, maxVal], and zero values map to the lowest color bucket instead of showing as empty. Why it happens: buildColorScale maps any value in [0, maxVal] to a color. Zero is technically in-domain. How to avoid: Either (a) filter out zero-value weekdays before passing to color scale and render them as empty cells, or (b) treat weekdays with dayCount === 0 as empty (no fill), similar to how year-mode handles null entries. Warning signs: All 7 cells colored even when user tracked on only 3 weekdays.

Pitfall 3: Controls Not Reflecting State After Re-render

What goes wrong: Mode or metric changes correctly, but if doRender() rebuilds the entire container, controls get destroyed. Why it happens: heatmap.ts's doRender() calls renderer.render(ctx) which does ctx.container.innerHTML = '' on the SVG area. If controls are inside the SVG area, they vanish. How to avoid: Controls live outside the SVG area container (in the card header via #heatmap-controls). The renderer only clears svgArea, not the card header. Warning signs: Controls disappear after first interaction.

Pitfall 4: Stats Row Showing Year-Specific Data in Week Mode

What goes wrong: Streak counter and "busiest day" stats show in week mode where they don't make contextual sense. Why it happens: renderStats() is called unconditionally after every doRender(). How to avoid: Either skip renderStats() when mode is 'week', or show week-appropriate stats (e.g., busiest weekday, average per weekday). Simplest: hide stats in week mode for now. Warning signs: "Busiest: Mon, Jan 13" showing below a weekday aggregation view.

Code Examples

Weekday Aggregation with Full Labels

// Full weekday names for tooltips (D-07 wants "Monday: 42.5h")
const WEEKDAY_NAMES_MONDAY = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const WEEKDAY_NAMES_SUNDAY = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];

function getWeekdayNames(weekStart: string): string[] {
  return weekStart === 'sunday' ? WEEKDAY_NAMES_SUNDAY : WEEKDAY_NAMES_MONDAY;
}

Week-Mode SVG Layout (7 horizontal cells)

// Larger cells since only 7 vs 365
const cellWidth = 60;
const cellHeight = 40;
const cellGap = 4;
const labelWidth = 50; // space for "Mon", "Tue" labels
const svgWidth = labelWidth + 7 * (cellWidth + cellGap);
const svgHeight = cellHeight + 20; // room for value text below

// Layout: label | rect | label | rect | ...
// Or: 7 rects in a row with labels below/beside

Wiring Controls to State in heatmap.ts

// In init(), after building wrapper:
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);
}

Validation Architecture

Test Framework

Property Value
Framework Vitest 4.1.3 + jsdom
Config file vitest.config.ts
Quick run command npm test
Full suite command npm test

Phase Requirements to Test Map

Req ID Behavior Test Type Automated Command File Exists?
VIZ-01 Mode switcher renders Year/Week buttons, click changes active state unit npx vitest run assets/test/controls.test.ts Wave 0
VIZ-02 Week renderer aggregates by weekday, renders 7 cells with correct colors unit npx vitest run assets/test/week.test.ts Wave 0
VIZ-05 Metric toggle switches state.metric, re-renders with different color values unit npx vitest run assets/test/controls.test.ts Wave 0
TEST-01 All mode/renderer/toggle tests pass suite npm test Wave 0

Sampling Rate

  • Per task commit: npm test
  • Per wave merge: npm test (single suite)
  • Phase gate: Full suite green before /gsd-verify-work

Wave 0 Gaps

  • assets/test/week.test.ts -- WeekModeRenderer rendering and aggregation tests
  • assets/test/controls.test.ts -- mode switcher and metric toggle interaction tests

Security Domain

No security-relevant ASVS categories apply to this phase. It is purely client-side UI rendering with no authentication, input handling from users, cryptography, or access control changes. Data comes from the existing authenticated API endpoint.

Assumptions Log

# Claim Section Risk if Wrong
A1 Tabler nav-segmented classes are available in Kimai's bundled Tabler version Architecture Patterns Controls would need custom CSS; verify in running Kimai instance
A2 The card-header area in Kimai's card embed template has enough space for controls next to the title Architecture Patterns May need to adjust Twig layout or use a different placement

Open Questions

  1. Stats row in week mode

    • What we know: Year-mode shows streak, total hours, avg hours, busiest day
    • What's unclear: Whether these make sense in week mode (streak is year-concept)
    • Recommendation: Hide stats in week mode initially; can add week-specific stats later if wanted
  2. Week-mode cell click behavior

    • What we know: Year-mode clicks navigate to timesheet for that date
    • What's unclear: What clicking "Monday" in week-mode should do (filter timesheet by weekday? no-op?)
    • Recommendation: No click handler for week cells initially -- the aggregation isn't tied to a single date

Sources

Primary (HIGH confidence)

  • Codebase: assets/src/renderers/types.ts, registry.ts, year.ts -- ModeRenderer contract and reference implementation
  • Codebase: assets/src/state.ts, assets/src/types.ts -- HeatmapState with mode/metric fields already defined
  • Codebase: assets/src/shared/color-scale.ts -- buildColorScale already metric-aware
  • Codebase: assets/src/shared/date-utils.ts -- weekday index calculation with start-of-week support

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • None

Metadata

Confidence breakdown:

  • Standard stack: HIGH -- no new packages, all already installed and tested
  • Architecture: HIGH -- extends well-established strategy pattern from Phase 6
  • Pitfalls: HIGH -- based on direct codebase analysis of existing patterns

Research date: 2026-04-09 Valid until: 2026-05-09 (stable -- no external dependency changes expected)