// Pure server-safe SVG bar chart for the Work Report's "Daily activity" // section. No hooks, no client APIs — this same component renders inside the // print-ready PDF template (`report-template.tsx`) AND the on-screen web/ // desktop `/reports` pages, so the chart a user downloads matches what they // saw. Colors are plain strings the caller passes in (CSS variables in the web // app, `--rep-*` variables in the PDF), never hardcoded here. // // Deterministic: every value is derived from the `daily` prop and its date // strings — no `Date.now()`, no randomness — so the same input always renders // the same markup. import type { ActivityReportDailyEntry } from "../utils/activity-report"; import { formatDuration } from "./report-template"; export interface DailyActivityChartProps { daily: ActivityReportDailyEntry[]; width?: number; height?: number; /** Bar fill color (e.g. an accent/primary token). */ barColor: string; /** Gridline color (a recessive/border token). */ gridColor: string; /** Axis tick-label color (a muted-text token). */ textColor: string; /** * Peak-day value label color. Optional so existing callers keep the * peak label matching `textColor`; the report template passes the accent * token here so the one emphasized number on the chart reads as "the * brand accent," while gridlines/ticks stay muted. */ peakLabelColor?: string; } const DEFAULT_WIDTH = 640; const DEFAULT_HEIGHT = 180; const MARGIN_TOP = 22; const MARGIN_RIGHT = 6; const MARGIN_BOTTOM = 22; const MAX_TICKS = 6; const BAR_MAX_FRACTION = 0.6; // bar width ≤ 60% of its day slot const BAR_MIN_GAP_PX = 2; const BAR_RADIUS_PX = 4; // Nice round gridline steps, smallest to largest. Picked so a chart never // shows minutes-precision gridlines ("2h 8m") — only clean multiples of one // of these ("30m", "1h", "2h", …). const NICE_STEP_MINUTES = [15, 30, 60, 120, 240, 480] as const; const GRIDLINE_MIN_COUNT = 3; const GRIDLINE_MAX_COUNT = 5; // Left-gutter sizing for the gridline "Nh" labels: an estimate of average // glyph width at fontSize 10 (deliberately generous) plus fixed padding, so // the widest label's right-aligned text never reaches the viewBox edge. const GRID_LABEL_CHAR_WIDTH_PX = 6.2; const GRID_LABEL_GAP_PX = 6; const GRID_LABEL_LEFT_PAD_PX = 4; const GRID_LABEL_MIN_GUTTER_PX = 24; /** Pick a nice round step + line count (3–5 lines) so the top gridline is the * smallest clean multiple of a "nice" step that is >= `maxMinutes`. */ function pickGridStep(maxMinutes: number): { step: number; count: number } { for (const step of NICE_STEP_MINUTES) { const count = Math.ceil(maxMinutes / step); if (count >= GRIDLINE_MIN_COUNT && count <= GRIDLINE_MAX_COUNT) { return { step, count }; } } const smallest = NICE_STEP_MINUTES[0]; if (maxMinutes / smallest < GRIDLINE_MIN_COUNT) { // Very small max: force the minimum gridline count on the smallest step. return { step: smallest, count: GRIDLINE_MIN_COUNT }; } // Very large max: fall back to the largest step, however many lines it takes. const largest = NICE_STEP_MINUTES[NICE_STEP_MINUTES.length - 1]; return { step: largest, count: Math.ceil(maxMinutes / largest) }; } function parseDayKey(date: string): Date { const [year, month, day] = date.split("-").map(Number); return new Date(year, month - 1, day); } function formatTickDate(date: string): string { return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", }).format(parseDayKey(date)); } function formatTitleDate(date: string): string { return new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", }).format(parseDayKey(date)); } /** Rounded-top, flat-bottom bar path — `rect rx` rounds all four corners, so a * bar anchored at y=0 needs a hand-built path to keep the baseline flat. */ function roundedTopBarPath( x: number, y: number, w: number, h: number, radius: number, ): string { const r = Math.max(0, Math.min(radius, w / 2, h)); return `M ${x} ${y + h} L ${x} ${y + r} Q ${x} ${y} ${x + r} ${y} L ${x + w - r} ${y} Q ${x + w} ${y} ${x + w} ${y + r} L ${x + w} ${y + h} Z`; } /** * A single-series daily-activity bar chart: thin bars, rounded tops only, a * few recessive gridlines, ~6 date ticks, and a value label on the peak day * only. Renders nothing (`null`) when there is no data or every day is zero — * callers already skip empty report sections around it. */ export function DailyActivityChart({ daily, width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, barColor, gridColor, textColor, peakLabelColor = textColor, }: DailyActivityChartProps) { if (daily.length === 0) return null; const maxMinutes = Math.max(...daily.map((d) => d.minutes)); if (maxMinutes <= 0) return null; // Headroom above the tallest bar so the peak-day label never touches the // top gridline, folded into the nice-step pick so the top gridline still // lands on a clean multiple of the chosen step. const headroomMinutes = Math.max(maxMinutes * 1.15, maxMinutes + 15); const { step: gridStep, count: gridCount } = pickGridStep(headroomMinutes); const yMaxMinutes = gridStep * gridCount; const gridLineMinutes = Array.from( { length: gridCount }, (_, i) => gridStep * (i + 1), ); const gridLineLabels = gridLineMinutes.map((m) => formatDuration(m)); // Size the left gutter to the widest gridline label so right-aligned text // never reaches (or crosses) the viewBox's left edge. const maxLabelChars = Math.max(...gridLineLabels.map((l) => l.length)); const marginLeft = Math.max( GRID_LABEL_MIN_GUTTER_PX, Math.ceil(maxLabelChars * GRID_LABEL_CHAR_WIDTH_PX) + GRID_LABEL_GAP_PX + GRID_LABEL_LEFT_PAD_PX, ); const plotWidth = width - marginLeft - MARGIN_RIGHT; const plotHeight = height - MARGIN_TOP - MARGIN_BOTTOM; const n = daily.length; const slotWidth = plotWidth / n; const barWidth = Math.max( 1, Math.min(slotWidth * BAR_MAX_FRACTION, slotWidth - BAR_MIN_GAP_PX), ); const gridLines = gridLineMinutes.map((minutes, i) => ({ minutes, label: gridLineLabels[i], y: MARGIN_TOP + plotHeight * (1 - minutes / yMaxMinutes), })); const peakIndex = daily.reduce( (best, entry, i) => (entry.minutes > daily[best].minutes ? i : best), 0, ); const tickCount = Math.min(MAX_TICKS, n); const tickIndices = [ ...new Set( tickCount <= 1 ? [0] : Array.from({ length: tickCount }, (_, i) => Math.round((i * (n - 1)) / (tickCount - 1)), ), ), ]; const totalMinutes = daily.reduce((sum, d) => sum + d.minutes, 0); const svgLabel = `Daily activity, ${formatTickDate(daily[0].date)} to ${formatTickDate(daily[n - 1].date)} — ${formatDuration(totalMinutes)} total`; return ( {gridLines.map((line) => ( {line.label} ))} {daily.map((entry, i) => { if (entry.minutes <= 0) return null; const x = marginLeft + i * slotWidth + (slotWidth - barWidth) / 2; const barHeight = (entry.minutes / yMaxMinutes) * plotHeight; const y = MARGIN_TOP + plotHeight - barHeight; const title = `${formatTitleDate(entry.date)} — ${formatDuration(entry.minutes)}`; return ( {title} ); })} {(() => { const peak = daily[peakIndex]; const peakX = marginLeft + peakIndex * slotWidth + slotWidth / 2; const peakBarHeight = (peak.minutes / yMaxMinutes) * plotHeight; const peakY = MARGIN_TOP + plotHeight - peakBarHeight; return ( {formatDuration(peak.minutes)} ); })()} {tickIndices.map((idx) => ( {formatTickDate(daily[idx].date)} ))} ); }