import { useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { formatUsd } from '../lib/format' import { localDateKey } from '../lib/period' import type { DailyHistoryEntry } from '../lib/types' type HeatmapDay = { date: string cost: number calls: number level: number isFuture: boolean } const WEEK_COUNT = 26 const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] function dateFromKey(key: string): Date { const [year, month, day] = key.split('-').map(Number) return new Date(year, month - 1, day) } function formatDate(key: string): string { return dateFromKey(key).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }) } function intensityLevel(cost: number, maxCost: number): number { if (cost <= 0 || maxCost <= 0) return 0 const ratio = Math.min(1, cost / maxCost) if (ratio < 0.25) return 1 if (ratio < 0.5) return 2 if (ratio < 0.75) return 3 return 4 } function buildHeatmapDays(daily: DailyHistoryEntry[], now: Date): HeatmapDay[] { const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const startOfWeek = new Date(today) startOfWeek.setDate(today.getDate() - today.getDay()) const firstDay = new Date(startOfWeek) firstDay.setDate(startOfWeek.getDate() - (WEEK_COUNT - 1) * 7) const byDate = new Map(daily.map(day => [day.date, day])) const visibleCosts: number[] = [] for (let offset = 0; offset < WEEK_COUNT * 7; offset++) { const date = new Date(firstDay) date.setDate(firstDay.getDate() + offset) if (date <= today) visibleCosts.push(byDate.get(localDateKey(date))?.cost ?? 0) } const maxCost = Math.max(...visibleCosts, 0) return Array.from({ length: WEEK_COUNT * 7 }, (_, offset) => { const date = new Date(firstDay) date.setDate(firstDay.getDate() + offset) const isFuture = date > today const entry = byDate.get(localDateKey(date)) const cost = isFuture ? 0 : (entry?.cost ?? 0) return { date: localDateKey(date), cost, calls: isFuture ? 0 : (entry?.calls ?? 0), level: intensityLevel(cost, maxCost), isFuture, } }) } export function ActivityHeatmap({ daily, bare = false }: { daily: DailyHistoryEntry[]; bare?: boolean }) { const days = useMemo(() => buildHeatmapDays(daily, new Date()), [daily]) const activeDays = days.filter(day => !day.isFuture && day.cost > 0).length const [tip, setTip] = useState<{ day: HeatmapDay; x: number; y: number } | null>(null) const [tipPosition, setTipPosition] = useState<{ left: number; top: number } | null>(null) const tipRef = useRef(null) useLayoutEffect(() => { if (!tip) { setTipPosition(null) return } const width = tipRef.current?.offsetWidth ?? 180 const height = tipRef.current?.offsetHeight ?? 58 const gutter = 8 const cursorGap = 12 let left = tip.x + cursorGap if (left + width > window.innerWidth - gutter) left = tip.x - width - cursorGap left = Math.max(gutter, Math.min(left, window.innerWidth - width - gutter)) let top = tip.y - height - cursorGap if (top < gutter) top = tip.y + cursorGap top = Math.max(gutter, Math.min(top, window.innerHeight - height - gutter)) setTipPosition({ left, top }) }, [tip]) const head = (
{bare ? Daily activity :

Daily activity

} {activeDays} active days
) const grid = (
{days.map(day => (
) const tooltip = tip ? createPortal(
{formatDate(tip.day.date)}
{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}
{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}
, document.body, ) : null if (bare) { return
{head}{grid}{tooltip}
} return (
{head}
{grid}
{tooltip}
) }