kimai-plugin-heatmap/.planning/phases/07-mode-switcher-week-mode/07-UI-SPEC.md
Christopher Mühl 32b00f7776
docs(07): UI design contract for mode switcher + week mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 10:51:28 +02:00

9.3 KiB

phase slug status shadcn_initialized preset created
7 mode-switcher-week-mode draft false none 2026-04-09

Phase 7 — UI Design Contract

Visual and interaction contract for the mode switcher, week-mode renderer, and hours/count toggle. Generated by gsd-ui-researcher.


Design System

Property Value
Tool none (Kimai plugin -- no shadcn)
Preset not applicable
Component library Tabler UI (bundled with Kimai)
Icon library none required this phase
Font var(--tblr-font-sans-serif) (inherited from Kimai/Tabler)

Spacing Scale

Declared values (must be multiples of 4):

Token Value Usage
xs 4px Gap between week-mode cells and labels, segmented control internal padding
sm 8px Gap between mode switcher and metric toggle controls
md 16px Gap between SVG area and filter dropdown (existing), stats row padding-top at 12px is the one exception
lg 24px Not used this phase
xl 32px Not used this phase

Exceptions: Stats row uses 12px padding-top (existing convention from heatmap-stats). Week-mode cell gap uses 4px between cells.


Typography

Role Size Weight Line Height CSS Source
Body / Stats 13px (0.8125rem) 400 1.5 Existing .heatmap-stats, .heatmap-tooltip
Label (weekday, axis) 10px 400 1.2 Existing .heatmap-label
Control text (segmented buttons) 13px (0.8125rem) 400 (normal), 600 (active) 1.5 Tabler nav-link default
Tooltip 13px (0.8125rem) 400 1.4 Existing .heatmap-tooltip

All fonts use var(--tblr-font-sans-serif). No custom font declarations.


Color

This phase introduces no new colors. All values come from Kimai's Tabler CSS variables and the existing heatmap color scale.

Role Value Usage
Dominant (60%) var(--tblr-bg-surface) Widget card background, tooltip background
Secondary (30%) var(--tblr-bg-surface-secondary) Empty heatmap cells (year and week mode)
Accent (10%) Green scale: #9be9a8, #40c463, #30a14e, #216e39 Heatmap cells with data (both year and week modes)
Border var(--tblr-border-color) Tooltip border
Text primary var(--tblr-body-color) Weekday labels, stat values, control text
Text secondary var(--tblr-secondary) fallback #6c757d Stats row labels
Active control Tabler nav-segmented .active default styling Active mode/metric button background highlight

Accent reserved for: heatmap cells with tracked data only (cells colored by buildColorScale() quantize scale across 4 green buckets). Zero-data weekday cells use the secondary empty fill, not the lowest green bucket.


Component Inventory

1. Mode Switcher (Segmented Control)

Property Value
Element <nav> with Tabler nav nav-segmented classes
Placement Inside #heatmap-controls div in card header, right-aligned via margin-left: auto
Items "Year" (key: year), "Week" (key: week)
Active state .active class on the selected nav-link button
ARIA role="tablist" on nav, role="tab" on each button, aria-selected on active
Behavior Click sets state.mode, calls doRender(). Does not re-fetch data.
Default "Year" active on load (matches createInitialState)

2. Metric Toggle (Compact Segmented Control)

Property Value
Element <nav> with Tabler nav nav-segmented nav-sm classes
Placement Adjacent to mode switcher inside #heatmap-controls, 8px gap
Items "Hours" (key: hours), "Count" (key: count)
Active state .active class on selected button
ARIA Same pattern as mode switcher
Behavior Click sets state.metric, calls doRender(). Does not re-fetch data.
Default "Hours" active on load

3. Controls Container (Twig Template Addition)

Property Value
Element <div id="heatmap-controls">
Placement Inside {% block box_title %}, wrapped in a flex container with the title
Layout display: flex; align-items: center; gap: 8px; margin-left: auto
Parent flex Title wrapper: display: flex; align-items: center; gap: 12px; flex-wrap: wrap

4. Week-Mode Renderer (SVG)

Property Value
Cell width 60px
Cell height 40px
Cell gap 4px
Cell border radius rx="2" ry="2" (matches .heatmap-cell)
Label width 50px (left of cells, for 3-letter weekday abbreviation)
Total SVG width 50 + 7 * (60 + 4) = 498px
Total SVG height 40px (single row, no extra margin needed)
Cell class heatmap-cell (reuses existing hover/cursor styles)
Empty cell class heatmap-empty (reuses existing empty fill)
Label class heatmap-label (reuses existing 10px label style)
Label position Vertically centered beside each cell, 50px column left of cells
Day order Follows state.weekStart -- Monday-first or Sunday-first
Click behavior None for this phase (weekday aggregation has no single-date target)

5. Week-Mode Tooltip

Property Value
Trigger Hover on week-mode cell
Content format (hours metric) "Monday: 42.5h (23 entries)"
Content format (count metric) "Monday: 23 entries (42.5h)"
Styling Existing .heatmap-tooltip class (no changes)
Position Above cursor, shared showTooltip/hideTooltip utilities

6. Stats Row Behavior

Mode Stats Row
Year Shown as-is (streak, total, avg, busiest day)
Week Hidden -- streak and busiest-date are year-specific concepts

Implementation: renderStats() call in doRender() is conditional on state.mode === 'year'. When mode is 'week', remove any existing .heatmap-stats element.


Interaction Contracts

Mode Switch: Year to Week

  1. User clicks "Week" in mode switcher
  2. "Week" button gets .active, "Year" button loses .active
  3. state.mode set to 'week'
  4. doRender() dispatches to WeekModeRenderer
  5. SVG area clears and renders 7 horizontal weekday cells
  6. Stats row is removed
  7. Scroll position resets (no horizontal scroll needed for 7 cells)
  8. Filter selection preserved (state.filters unchanged)

Mode Switch: Week to Year

  1. User clicks "Year" in mode switcher
  2. Active states swap
  3. state.mode set to 'year'
  4. doRender() dispatches to YearModeRenderer
  5. SVG area clears and renders full year calendar grid
  6. Stats row reappears
  7. SVG area scrolls to right end (existing behavior)

Metric Toggle

  1. User clicks "Count" (or "Hours")
  2. Active state swaps on metric toggle buttons
  3. state.metric updated
  4. doRender() re-renders current mode with new metric
  5. Color scale recalculated via buildColorScale(days, state.metric)
  6. No data re-fetch

No Transition Animation

Mode switches are instant (clear + re-render). No CSS transitions or d3 transitions between modes. This keeps implementation simple and avoids visual glitches with different SVG structures.


Copywriting Contract

Element Copy
Mode switcher labels "Year", "Week"
Metric toggle labels "Hours", "Count"
Week tooltip (hours mode) "{Weekday}: {N}h ({N} entries)" -- e.g. "Monday: 42.5h (23 entries)"
Week tooltip (count mode) "{Weekday}: {N} entries ({N}h)" -- e.g. "Monday: 23 entries (42.5h)"
Week empty cell tooltip "{Weekday}: No tracked time"
Data load error "Failed to load heatmap data" (existing, unchanged)
Empty state (no data, week mode) 7 cells all rendered with .heatmap-empty fill, no special empty-state message needed (the empty cells are self-explanatory)
Project filter empty (week mode) "No tracking data for this project" (existing emptyMessage, rendered as text in SVG area)

No destructive actions in this phase.


Registry Safety

Registry Blocks Used Safety Gate
Not applicable No shadcn, no registries N/A

This is a Symfony/Kimai plugin using Tabler CSS bundled with the host application. No component registry applies.


CSS Additions

Only one new CSS rule needed:

/* Week-mode cells: no pointer cursor since they are not clickable */
.heatmap-week-cell {
  cursor: default;
}

All other styling reuses existing classes: .heatmap-cell, .heatmap-empty, .heatmap-label, .heatmap-tooltip. The Tabler nav-segmented classes handle control styling with zero custom CSS.


Twig Template Change

The {% block box_title %} in heatmap.html.twig changes 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 %}

JS populates #heatmap-controls at init time. The inline styles are intentional -- this is a minimal layout wrapper, not a reusable component.


Checker Sign-Off

  • Dimension 1 Copywriting: PASS
  • Dimension 2 Visuals: PASS
  • Dimension 3 Color: PASS
  • Dimension 4 Typography: PASS
  • Dimension 5 Spacing: PASS
  • Dimension 6 Registry Safety: PASS

Approval: pending