From e0fce6600679e8a3faccc0faf854c596632240d8 Mon Sep 17 00:00:00 2001 From: Akinloluwa20 <112554977+Akinloluwa20@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:55:54 +0000 Subject: [PATCH] feat(frontend): add ActivityHeatmap calendar of stream activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ActivityHeatmap component rendering a GitHub-style calendar of the past 12 months, coloring each day by its count of created/claimed/canceled stream events. Empty days render gray with an accessible aria-label and accurate per-day tooltip, plus an intensity legend. Generated with Codebuff πŸ€– Co-Authored-By: Codebuff --- .../src/components/ActivityHeatmap.test.tsx | 125 ++++++ frontend/src/components/ActivityHeatmap.tsx | 373 ++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 frontend/src/components/ActivityHeatmap.test.tsx create mode 100644 frontend/src/components/ActivityHeatmap.tsx diff --git a/frontend/src/components/ActivityHeatmap.test.tsx b/frontend/src/components/ActivityHeatmap.test.tsx new file mode 100644 index 0000000..f7ebb4b --- /dev/null +++ b/frontend/src/components/ActivityHeatmap.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { ActivityHeatmap } from "./ActivityHeatmap"; +import type { StreamEvent } from "../services/api"; + +vi.mock("../services/api", () => ({ + listAllEvents: vi.fn(), +})); + +import { listAllEvents } from "../services/api"; + +const mockListAllEvents = listAllEvents as ReturnType; + +const makeEvent = (id: number, eventType: StreamEvent["eventType"], timestamp: number): StreamEvent => ({ + id, + streamId: "stream-1", + eventType, + timestamp, +}); + +/** A timestamp for a specific local calendar date at noon to avoid TZ edge cases. */ +function at(y: number, m: number, d: number): number { + return new Date(y, m - 1, d, 12, 0, 0).getTime() / 1000; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("ActivityHeatmap", () => { + it("shows a loading state while fetching", async () => { + mockListAllEvents.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText(/Loading activity/i)).toBeTruthy(); + }); + + it("renders the heatmap grid populated from fetched events", async () => { + mockListAllEvents.mockResolvedValue([makeEvent(1, "created", at(2026, 7, 15))]); + render(); + await waitFor(() => expect(screen.queryByText(/Loading activity/i)).toBeNull()); + + // Each of the 52 weeks x 7 days = 364 day cells. + const cells = screen.getAllByRole("img"); + expect(cells.length).toBe(364); + }); + + it("counts only created/claimed/canceled events and ignores other event types", async () => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + const mid = now.getTime() / 1000 + 12 * 3600; + + mockListAllEvents.mockResolvedValue([ + makeEvent(1, "created", mid), + makeEvent(2, "claimed", mid), + makeEvent(3, "canceled", mid), + makeEvent(4, "paused", mid), // ignored + ]); + + render(); + await waitFor(() => expect(screen.queryByText(/Loading activity/i)).toBeNull()); + + expect(screen.getByText(/3 activity events in the last 12 months/i)).toBeTruthy(); + + // The day cell's aria-label reflects the activity count. + const todayFmt = now.toLocaleDateString([], { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); + expect(screen.getByLabelText(`${todayFmt}: 3 stream events`)).toBeTruthy(); + }); + + it("renders gray cells for empty days with an accessible 'no activity' label", async () => { + mockListAllEvents.mockResolvedValue([makeEvent(1, "created", at(2026, 1, 1))]); + render(); + await waitFor(() => expect(screen.queryByText(/Loading activity/i)).toBeNull()); + + // The first cell of the grid is a real past day with no events. + const cells = screen.getAllByRole("img"); + const first = cells[0]; + expect(first.getAttribute("aria-label")).toMatch(/no activity/i); + // Empty days render as gray (level 0) rather than transparent. + const style = first.getAttribute("style") ?? ""; + expect(style).toMatch(/background-color:\s*#e5e7eb|rgb\(229\s*,\s*231\s*,\s*235\)/); + }); + + it("renders empty state when no events exist", async () => { + mockListAllEvents.mockResolvedValue([]); + render(); + await waitFor(() => expect(screen.queryByText(/Loading activity/i)).toBeNull()); + expect(screen.getByText(/0 activity events in the last 12 months/i)).toBeTruthy(); + }); + + it("renders an error state with a retry button", async () => { + mockListAllEvents.mockRejectedValue(new Error("Network down")); + render(); + await waitFor(() => expect(screen.getByText(/Network down/i)).toBeTruthy()); + + const retry = screen.getByText(/Try again/i); + expect(retry).toBeTruthy(); + + // Retry refetches and renders. + mockListAllEvents.mockResolvedValue([]); + retry.click(); + await waitFor(() => expect(screen.getByText(/0 activity events/i)).toBeTruthy()); + expect(mockListAllEvents).toHaveBeenCalledTimes(2); + }); + + it("supports injected events via the events prop without fetching", async () => { + render( + , + ); + expect(mockListAllEvents).not.toHaveBeenCalled(); + expect(screen.getAllByRole("img").length).toBe(364); + expect(screen.getByText(/1 activity event in the last 12 months/i)).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/ActivityHeatmap.tsx b/frontend/src/components/ActivityHeatmap.tsx new file mode 100644 index 0000000..fcf10fc --- /dev/null +++ b/frontend/src/components/ActivityHeatmap.tsx @@ -0,0 +1,373 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { listAllEvents, StreamEvent } from "../services/api"; + +// Event types counted as "activity" on the heatmap. Other event types +// (pauses, resumes, start-time updates, etc.) do not contribute. +const ACTIVITY_EVENT_TYPES: ReadonlySet = new Set([ + "created", + "claimed", + "canceled", +]); + +interface ActivityHeatmapProps { + /** + * Optional pre-fetched events. When omitted the component fetches all events + * from the API via `listAllEvents()`. + */ + events?: StreamEvent[]; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; +} + +interface HeatmapDay { + date: Date; + key: string; // yyyy-mm-dd + count: number; + isFuture: boolean; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEKS = 52; // last 12 months β‰ˆ 52 weeks +const TOTAL_DAYS = WEEKS * 7; +const MONTH_LABELS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +/** Format a date as yyyy-mm-dd (local time) for stable keys and lookups. */ +function toKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function formatDayLabel(date: Date): string { + return date.toLocaleDateString([], { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); +} + +/** + * Map a daily count to a 0–4 intensity level. Level 0 (gray) is used for empty + * days; higher levels scale against the per-day max so the busiest day always + * renders darkest. + */ +function colorLevel(count: number, max: number): number { + if (count <= 0 || max <= 0) return 0; + const ratio = count / max; + if (ratio >= 0.75) return 4; + if (ratio >= 0.5) return 3; + if (ratio >= 0.25) return 2; + return 1; +} + +// Background colors by intensity level. +const LEVEL_COLORS: readonly string[] = [ + "#e5e7eb", // level 0 β€” gray / empty day + "#dbeafe", // level 1 β€” faint blue + "#93c5fd", // level 2 β€” light blue + "#3b82f6", // level 3 β€” medium blue + "#1e3a8a", // level 4 β€” darkest blue +]; + +export function ActivityHeatmap({ + events: eventsProp, + loading: loadingProp, + error: errorProp, + onRetry, +}: ActivityHeatmapProps) { + const [fetchedEvents, setFetchedEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hoveredDay, setHoveredDay] = useState(null); + const [hoverPosition, setHoverPosition] = useState<{ + top: number; + left: number; + } | null>(null); + const containerRef = useRef(null); + + useEffect(() => { + if (eventsProp) { + setLoading(false); + return; + } + let mounted = true; + setLoading(true); + setError(null); + listAllEvents() + .then((data) => { + if (mounted) setFetchedEvents(data); + }) + .catch((err) => { + if (mounted) setError(err instanceof Error ? err : new Error("Failed to load activity.")); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { + mounted = false; + }; + }, [eventsProp]); + + const handleRetry = () => { + if (eventsProp) return; + setError(null); + setLoading(true); + listAllEvents() + .then(setFetchedEvents) + .catch((err) => setError(err instanceof Error ? err : new Error("Failed to load activity."))) + .finally(() => setLoading(false)); + }; + + const events: StreamEvent[] = eventsProp ?? fetchedEvents; + const isLoading = loadingProp ?? loading; + const hasError = errorProp ?? error; + + const { cells, maxCount } = useMemo(() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const start = new Date(today.getTime() - (TOTAL_DAYS - 1) * DAY_MS); + const now = Date.now(); + + // Tally events by day, counting only activity event types. + const counts = new Map(); + let max = 0; + for (const event of events) { + if (!ACTIVITY_EVENT_TYPES.has(event.eventType)) continue; + const key = toKey(new Date(event.timestamp * 1000)); + const next = (counts.get(key) ?? 0) + 1; + counts.set(key, next); + if (next > max) max = next; + } + + const out: HeatmapDay[] = []; + for (let i = 0; i < TOTAL_DAYS; i++) { + const date = new Date(start.getTime() + i * DAY_MS); + const key = toKey(date); + out.push({ + date, + key, + isFuture: date.getTime() > now, + count: counts.get(key) ?? 0, + }); + } + return { cells: out, maxCount: max }; + }, [events]); + + // Group cells into week columns (7 days each). + const weeks = useMemo(() => { + const cols: HeatmapDay[][] = []; + for (let w = 0; w < WEEKS; w++) cols.push(cells.slice(w * 7, w * 7 + 7)); + return cols; + }, [cells]); + + // Month label for the column in which each new month first appears. + const monthLabels = useMemo(() => { + const labels: { col: number; label: string }[] = []; + let prevMonth = -1; + cells.forEach((cell, idx) => { + if (cell.date.getMonth() !== prevMonth) { + labels.push({ col: Math.floor(idx / 7), label: MONTH_LABELS[cell.date.getMonth()] }); + prevMonth = cell.date.getMonth(); + } + }); + return labels; + }, [cells]); + + const handleMouseMove = (e: React.MouseEvent) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + setHoverPosition({ top: e.clientY - rect.top, left: e.clientX - rect.left }); + }; + + const hideTooltip = () => { + setHoveredDay(null); + setHoverPosition(null); + }; + + if (isLoading) { + return ( +
+

Loading activity…

+
+ ); + } + + if (hasError) { + return ( +
+

{hasError.message || "Failed to load activity."}

+ +
+ ); + } + + const activityCount = events.filter((e) => ACTIVITY_EVENT_TYPES.has(e.eventType)).length; + + return ( +
+
+ {/* Month label row aligned above each week column */} +
+ {Array.from({ length: 7 }, (_, row) => ( + + ))} +
+ +
+ {/* Month label row */} + + +
+ {weeks.map((week, col) => ( +
+ {week.map((day) => { + const level = colorLevel(day.count, maxCount); + return ( +
0 + ? `${formatDayLabel(day.date)}: ${day.count} stream event${day.count === 1 ? "" : "s"}` + : `${formatDayLabel(day.date)}: no activity` + } + title={ + day.isFuture + ? formatDayLabel(day.date) + : day.count > 0 + ? `${formatDayLabel(day.date)} β€” ${day.count} event${day.count === 1 ? "" : "s"}` + : `${formatDayLabel(day.date)} β€” no activity` + } + onMouseMove={handleMouseMove} + onMouseEnter={() => setHoveredDay(day)} + style={{ + backgroundColor: day.isFuture ? "transparent" : LEVEL_COLORS[level], + width: "100%", + aspectRatio: "1 / 1", + borderRadius: "2px", + minHeight: 11, + }} + /> + ); + })} +
+ ))} +
+
+
+ + {/* Tooltip */} + {hoveredDay && hoverPosition && ( +
+ {formatDayLabel(hoveredDay.date)} +
+ {hoveredDay.count > 0 + ? `${hoveredDay.count} stream event${hoveredDay.count === 1 ? "" : "s"}` + : "No activity"} +
+
+ )} + + {/* Legend */} +
+ Less + {LEVEL_COLORS.map((color) => ( +
+
+ ); +} \ No newline at end of file