style(app): merge hero + daily-activity heatmap into one split card

Per feedback, the hero (number + KPIs) and the git-style daily-activity
heatmap now share a single card, split left/right with a divider.
ActivityHeatmap gains a 'bare' mode (renders head+grid without its own
card wrapper). Efficiency scorecard + fuel ring move to a row below.
This commit is contained in:
iamtoruk 2026-07-11 11:30:15 -07:00
parent 7ad1b76a8c
commit 9bc06e74ad
3 changed files with 63 additions and 39 deletions

View file

@ -70,7 +70,7 @@ function buildHeatmapDays(daily: DailyHistoryEntry[], now: Date): HeatmapDay[] {
})
}
export function ActivityHeatmap({ daily }: { daily: DailyHistoryEntry[] }) {
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)
@ -95,41 +95,42 @@ export function ActivityHeatmap({ daily }: { daily: DailyHistoryEntry[] }) {
setTipPosition({ left, top })
}, [tip])
return (
<div className="ov-card ov-panel ov-heatmap-panel">
<div className="ov-panel-head">
<h3>Daily activity</h3>
<span className="r ov-active-days">{activeDays} active days</span>
</div>
<div className="ov-panel-body">
<div className="ov-heatmap-scroll">
<div className="ov-heatmap" role="grid" aria-label="Daily activity contribution heatmap">
<div className="ov-heatmap-labels" aria-hidden="true">
{WEEKDAYS.map((weekday, index) => (
<span key={weekday}>{index === 1 || index === 3 || index === 5 ? weekday : ''}</span>
))}
</div>
<div className="ov-heatmap-cells">
{days.map(day => (
<button
type="button"
role="gridcell"
key={day.date}
className={`ov-heat-cell heat-level-${day.level}${day.isFuture ? ' future' : ''}`}
aria-label={`${formatDate(day.date)}: ${day.isFuture ? 'future day' : `${formatUsd(day.cost)}, ${day.calls} calls`}`}
data-date={day.date}
data-cost={day.cost}
data-active={!day.isFuture && day.cost > 0 ? 'true' : 'false'}
onMouseEnter={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseMove={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseLeave={() => setTip(null)}
/>
))}
</div>
</div>
const head = (
<div className={bare ? 'ov-activity-head' : 'ov-panel-head'}>
{bare ? <span className="ov-label">Daily activity</span> : <h3>Daily activity</h3>}
<span className="r ov-active-days">{activeDays} active days</span>
</div>
)
const grid = (
<div className="ov-heatmap-scroll">
<div className="ov-heatmap" role="grid" aria-label="Daily activity contribution heatmap">
<div className="ov-heatmap-labels" aria-hidden="true">
{WEEKDAYS.map((weekday, index) => (
<span key={weekday}>{index === 1 || index === 3 || index === 5 ? weekday : ''}</span>
))}
</div>
<div className="ov-heatmap-cells">
{days.map(day => (
<button
type="button"
role="gridcell"
key={day.date}
className={`ov-heat-cell heat-level-${day.level}${day.isFuture ? ' future' : ''}`}
aria-label={`${formatDate(day.date)}: ${day.isFuture ? 'future day' : `${formatUsd(day.cost)}, ${day.calls} calls`}`}
data-date={day.date}
data-cost={day.cost}
data-active={!day.isFuture && day.cost > 0 ? 'true' : 'false'}
onMouseEnter={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseMove={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseLeave={() => setTip(null)}
/>
))}
</div>
</div>
{tip && createPortal(
</div>
)
const tooltip = tip
? createPortal(
<div
ref={tipRef}
className={`chart-tip${tipPosition ? ' on' : ''}`}
@ -141,7 +142,17 @@ export function ActivityHeatmap({ daily }: { daily: DailyHistoryEntry[] }) {
<div className="chart-tip-s">{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}</div>
</div>,
document.body,
)}
)
: null
if (bare) {
return <div className="ov-heatmap-bare">{head}{grid}{tooltip}</div>
}
return (
<div className="ov-card ov-panel ov-heatmap-panel">
{head}
<div className="ov-panel-body">{grid}</div>
{tooltip}
</div>
)
}

View file

@ -574,8 +574,8 @@ export function OverviewContent({
const anomalies = deriveAnomalies(data, now)
return (
<div className="ov-dashboard">
<div className="ov-hero-row">
<div className="ov-card ov-hero">
<div className="ov-card ov-hero-split">
<div className="ov-hero-main">
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={data.current.cost} />
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
@ -585,12 +585,14 @@ export function OverviewContent({
<div className="ov-hero-kpi ov-hero-kpi-saved"><span>Saved</span><strong>{formatUsd(saved)}</strong><small>from {applied} applied fixes</small></div>
</div>
</div>
<ActivityHeatmap daily={data.history.daily} bare />
</div>
<div className="ov-hero-secondary">
<EfficiencyScorecard current={data.current} />
<FuelRing status={plans.data} onNavigate={onNavigate} />
</div>
<ActivityHeatmap daily={data.history.daily} />
<div className="ov-coach">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/></svg>
<div className="ov-coach-tx">

View file

@ -223,6 +223,17 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.ov-hero-kpi small { color: var(--mut2); font-size: 10px; margin-top: 1px; }
.ov-hero-kpi-accent > strong { color: var(--accent); }
.ov-hero-kpi-saved > strong { color: var(--ok); }
/* Hero + daily-activity heatmap share one split card. */
.ov-hero-split { display: grid; grid-template-columns: minmax(270px, 330px) minmax(0, 1fr); gap: 22px; padding: 16px 18px; align-items: stretch; }
.ov-hero-main { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.ov-hero-split .ov-heatmap-bare { display: flex; flex-direction: column; justify-content: center; gap: 9px; min-width: 0; border-left: 1px solid var(--line2); padding-left: 22px; }
.ov-activity-head { display: flex; align-items: center; justify-content: space-between; }
.ov-hero-secondary { display: grid; grid-template-columns: minmax(0, 1fr) 196px; gap: 12px; }
@media (max-width: 900px) {
.ov-hero-split { grid-template-columns: 1fr; }
.ov-hero-split .ov-heatmap-bare { border-left: 0; padding-left: 0; border-top: 1px solid var(--line2); padding-top: 12px; }
.ov-hero-secondary { grid-template-columns: 1fr; }
}
.ov-hero-sub .neutral { color: var(--mut); font-weight: 560; }
.ov-efficiency { display: flex; min-width: 0; flex-direction: column; padding: 14px 16px; }
.ov-efficiency-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }