diff --git a/client/src/components/ActivityHeatmap.tsx b/client/src/components/ActivityHeatmap.tsx new file mode 100644 index 00000000..876dce5c --- /dev/null +++ b/client/src/components/ActivityHeatmap.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from "react"; +import type { LapMeta } from "@shared/types"; + +const CELL = 11; +const GAP = 3; +const WEEKS = 53; +const DAYS = 7; +const MONTH_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; + +function dayKey(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function intensity(v: number, max: number): number { + if (v <= 0 || max <= 0) return 0; + const pct = v / max; + if (pct <= 0.25) return 1; + if (pct <= 0.5) return 2; + if (pct <= 0.75) return 3; + return 4; +} + +function fmtDuration(sec: number): string { + if (sec <= 0) return "0m"; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = Math.floor(sec % 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +const LEVEL_COLORS = [ + "var(--color-app-surface-alt, #1a1d26)", + "rgba(139, 92, 246, 0.25)", + "rgba(139, 92, 246, 0.5)", + "rgba(139, 92, 246, 0.75)", + "rgba(139, 92, 246, 1)", +]; + +export function ActivityHeatmap({ laps }: { laps: LapMeta[] }) { + const [hover, setHover] = useState<{ date: string; seconds: number; x: number; y: number } | null>(null); + + const { cells, max, totalDays, totalSeconds, monthMarkers } = useMemo(() => { + const secs = new Map(); + for (const lap of laps) { + if (lap.lapTime <= 0) continue; + const key = dayKey(new Date(lap.createdAt)); + secs.set(key, (secs.get(key) ?? 0) + lap.lapTime); + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const endDow = today.getDay(); + const start = new Date(today); + start.setDate(today.getDate() - ((WEEKS - 1) * 7 + endDow)); + + const grid: { date: Date; key: string; seconds: number }[][] = []; + const months: { week: number; label: string }[] = []; + let lastMonth = -1; + let maxSec = 0; + let daysActive = 0; + let totalSec = 0; + + for (let w = 0; w < WEEKS; w++) { + const col: { date: Date; key: string; seconds: number }[] = []; + for (let d = 0; d < DAYS; d++) { + const date = new Date(start); + date.setDate(start.getDate() + w * 7 + d); + const key = dayKey(date); + const seconds = secs.get(key) ?? 0; + col.push({ date, key, seconds }); + if (date > today) continue; + if (seconds > maxSec) maxSec = seconds; + if (seconds > 0) { + daysActive++; + totalSec += seconds; + } + if (d === 0 && date.getMonth() !== lastMonth) { + months.push({ week: w, label: MONTH_LABELS[date.getMonth()] }); + lastMonth = date.getMonth(); + } + } + grid.push(col); + } + + return { cells: grid, max: maxSec, totalDays: daysActive, totalSeconds: totalSec, monthMarkers: months }; + }, [laps]); + + const width = WEEKS * (CELL + GAP); + const height = DAYS * (CELL + GAP); + const todayKey = dayKey(new Date()); + + return ( +
+
+

+ Activity — Last 12 Months +

+
+ {fmtDuration(totalSeconds)} · {totalDays} active days +
+
+
+
+
+ {DAY_LABELS.map((l, i) => ( +
{l}
+ ))} +
+
+
+ {monthMarkers.map((m, i) => ( +
+ {m.label} +
+ ))} +
+ + {cells.map((col, w) => + col.map(({ date, key, seconds }, d) => { + const future = date > new Date(); + const lvl = intensity(seconds, max); + const isToday = key === todayKey; + return ( + { + if (future) return; + const rect = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect(); + setHover({ + date: date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" }), + seconds, + x: w * (CELL + GAP) + rect.left + CELL / 2, + y: d * (CELL + GAP) + rect.top, + }); + }} + onMouseLeave={() => setHover(null)} + /> + ); + }) + )} + +
+ Less + {LEVEL_COLORS.map((c, i) => ( + + ))} + More +
+
+
+ {hover && ( +
+
{hover.seconds > 0 ? fmtDuration(hover.seconds) : "No activity"}
+
{hover.date}
+
+ )} +
+
+ ); +} diff --git a/client/src/components/HomePage.tsx b/client/src/components/HomePage.tsx index 9b4c3aec..2abf2518 100644 --- a/client/src/components/HomePage.tsx +++ b/client/src/components/HomePage.tsx @@ -10,6 +10,7 @@ import { tryGetGame } from "@shared/games/registry"; import { useUiStore } from "../stores/ui"; import { PiBadge, PI_COLORS, piClass } from "./forza/PiBadge"; import { Table, THead, TBody, TRow, TH, TD } from "./ui/AppTable"; +import { ActivityHeatmap } from "./ActivityHeatmap"; function StatCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) { @@ -159,6 +160,7 @@ export function HomePage() { const totalTime = laps.reduce((s, l) => s + (l.lapTime > 0 ? l.lapTime : 0), 0); const tracks = new Set(laps.map((l) => l.trackOrdinal).filter(Boolean)).size; const cars = new Set(laps.map((l) => l.carOrdinal).filter(Boolean)).size; + const sessions = new Set(laps.map((l) => l.sessionId).filter(Boolean)).size; const carCounts = new Map(); for (const l of laps) { if (l.carOrdinal) carCounts.set(l.carOrdinal, (carCounts.get(l.carOrdinal) ?? 0) + 1); @@ -168,7 +170,7 @@ export function HomePage() { for (const [ord, count] of carCounts) { if (count > favCarCount) { favCarOrd = ord; favCarCount = count; } } - return { laps: laps.length, valid: valid.length, best, avgTime, totalTime, tracks, cars, favCarOrd, favCarCount }; + return { laps: laps.length, valid: valid.length, best, avgTime, totalTime, tracks, cars, sessions, favCarOrd, favCarCount }; } const gameLaps = gameId ? allLaps.filter((l) => l.gameId === gameId) : allLaps; @@ -435,6 +437,9 @@ export function HomePage() { } } + {/* Activity heatmap */} + l.gameId === gameId) : allLaps} /> + {/* Period tabs + stats */}
@@ -457,7 +462,8 @@ export function HomePage() { return h > 0 ? `${h}h ${m}m` : `${m}m`; }; return ( -
+
+