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
- User clicks "Week" in mode switcher
- "Week" button gets
.active, "Year" button loses.active state.modeset to'week'doRender()dispatches toWeekModeRenderer- SVG area clears and renders 7 horizontal weekday cells
- Stats row is removed
- Scroll position resets (no horizontal scroll needed for 7 cells)
- Filter selection preserved (
state.filtersunchanged)
Mode Switch: Week to Year
- User clicks "Year" in mode switcher
- Active states swap
state.modeset to'year'doRender()dispatches toYearModeRenderer- SVG area clears and renders full year calendar grid
- Stats row reappears
- SVG area scrolls to right end (existing behavior)
Metric Toggle
- User clicks "Count" (or "Hours")
- Active state swaps on metric toggle buttons
state.metricupdateddoRender()re-renders current mode with new metric- Color scale recalculated via
buildColorScale(days, state.metric) - 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