From c2fb918fc5ed1227015002370de5eacdfc661958 Mon Sep 17 00:00:00 2001 From: reviewer Date: Mon, 20 Jul 2026 22:20:50 +0200 Subject: [PATCH] dash: add Workflow and Spend punchcard panels Workflow panel sits beside Model efficiency and surfaces correction rate (with count), median time to first edit, pricing coverage (rendered only when a real number, never as 100% for unknown), and the top reworked files (basenames with session and edit counts). The whole panel hides when the payload carries no workflow block, so an older peer renders the dashboard unchanged. Spend punchcard is an hour-of-day by weekday matrix computed client-side from history.timeline, dot size and intensity encoding spend, with a per-cell tooltip. Cells with no bucket render empty, the panel hides when the timeline is absent, and daily-bucket periods show an honest notice instead of faking hour-of-day resolution. --- dash/src/App.tsx | 22 +++- dash/src/components/Punchcard.tsx | 174 ++++++++++++++++++++++++++ dash/src/components/WorkflowPanel.tsx | 109 ++++++++++++++++ dash/src/lib/api.ts | 21 ++++ 4 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 dash/src/components/Punchcard.tsx create mode 100644 dash/src/components/WorkflowPanel.tsx diff --git a/dash/src/App.tsx b/dash/src/App.tsx index 6b1e5a75..fccbdb0d 100644 --- a/dash/src/App.tsx +++ b/dash/src/App.tsx @@ -21,6 +21,8 @@ import { DataTable } from '@/components/DataTable' import { GranularUsageChart, DeviceUsageChart, type Unit } from '@/components/UsageChart' import { DeviceSearchModal } from '@/components/DeviceSearchModal' import { ContextExplorer } from '@/components/ContextExplorer' +import { WorkflowPanel, hasWorkflowContent } from '@/components/WorkflowPanel' +import { Punchcard } from '@/components/Punchcard' const n = (v: number | undefined): number => v ?? 0 @@ -86,6 +88,15 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote: const activityBars: BarItem[] = c ? c.topActivities.filter((a) => a.cost > 0).map((a) => ({ name: a.name, value: a.cost, display: usd(a.cost) })) : [] + // Workflow rides beside Model efficiency only when it carries data. Without + // it the row is a single full-width Model efficiency panel, so an older peer + // (no workflow block) renders exactly as the dashboard did before. + const workflowPanel = c && hasWorkflowContent(c) ? ( + + + + ) : null + const timeline = payload?.history.timeline return ( <> @@ -150,7 +161,7 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote: -
+
+ {workflowPanel}
+ {timeline && ( +
+ + + +
+ )} +
{isRemote ? ( diff --git a/dash/src/components/Punchcard.tsx b/dash/src/components/Punchcard.tsx new file mode 100644 index 00000000..203d0514 --- /dev/null +++ b/dash/src/components/Punchcard.tsx @@ -0,0 +1,174 @@ +import { useMemo, useRef, useState } from 'react' + +import type { GranularHistory } from '@/lib/api' +import { usd } from '@/lib/utils' + +const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +const WEEKDAYS_FULL = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] +const HOURS = Array.from({ length: 24 }, (_, h) => h) + +type Cell = { cost: number; covered: boolean } + +function pad2(v: number): string { + return String(v).padStart(2, '0') +} + +// Mon-first weekday index from a Date. getDay() is 0=Sun..6=Sat. +function weekdayIndex(d: Date): number { + return (d.getDay() + 6) % 7 +} + +// Perceptual ramp: sqrt keeps small spends visible against the largest cell +// instead of collapsing to an invisible dot. +function intensity(cost: number, max: number): number { + if (max <= 0 || cost <= 0) return 0 + return Math.sqrt(cost / max) +} + +export function Punchcard({ timeline }: { timeline: GranularHistory }) { + const wrapRef = useRef(null) + const [hover, setHover] = useState<{ x: number; y: number; wd: number; h: number } | null>(null) + + const { grid, max, hasBucket } = useMemo(() => { + const g: Cell[][] = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => ({ cost: 0, covered: false }))) + let m = 0 + let any = false + for (const p of timeline.points) { + const d = new Date(p.timestamp) + if (!Number.isFinite(d.getTime())) continue + any = true + const cell = g[weekdayIndex(d)]![d.getHours()]! + cell.covered = true + cell.cost += p.cost > 0 ? p.cost : 0 + if (cell.cost > m) m = cell.cost + } + return { grid: g, max: m, hasBucket: any } + }, [timeline]) + + // Buckets coarser than an hour carry no hour-of-day signal: every daily bucket + // is timestamped at local midnight, so plotting it would assert all spend + // happened at hour 0. Show the honest limitation instead of a fake column. + const hourResolved = timeline.bucketMinutes < 1440 + + const bucketNote = timeline.bucketMinutes >= 60 ? 'Hourly buckets' : `${timeline.bucketMinutes}-minute buckets` + + if (!hasBucket) { + return
No timestamped usage in this period.
+ } + + if (!hourResolved) { + return ( +
+ Hour-of-day detail needs sub-daily buckets. Switch to Today or 7 days to see the punchcard. +
+ ) + } + + const hovered = hover ? grid[hover.wd]![hover.h]! : null + + return ( +
+
+ {bucketNote} · local time +
+ Less + {[0.12, 0.4, 0.7, 1].map((t) => ( + + ))} + More +
+
+ +
+
setHover(null)} + > + {/* Hour axis */} +
+ + {HOURS.map((h) => ( + + {h % 3 === 0 ? h : ''} + + ))} +
+ + {/* Weekday rows */} + {WEEKDAYS.map((wdLabel, wd) => ( +
+ {wdLabel} + {HOURS.map((h) => { + const cell = grid[wd]![h]! + const t = intensity(cell.cost, max) + const active = hover?.wd === wd && hover?.h === h + return ( +
{ + if (!cell.covered || !wrapRef.current) return + const r = wrapRef.current.getBoundingClientRect() + setHover({ x: e.clientX - r.left, y: e.clientY - r.top, wd, h }) + }} + onMouseMove={(e) => { + if (!cell.covered || !wrapRef.current) return + const r = wrapRef.current.getBoundingClientRect() + setHover({ x: e.clientX - r.left, y: e.clientY - r.top, wd, h }) + }} + > +
+ {cell.cost > 0 ? ( +
+ ) : cell.covered ? ( +
+ ) : null} +
+
+ ) + })} +
+ ))} + + {hover && hovered && ( +
+
+ {WEEKDAYS_FULL[hover.wd]} {pad2(hover.h)}:00 +
+
{usd(hovered.cost)}
+
+ )} +
+
+
+ ) +} diff --git a/dash/src/components/WorkflowPanel.tsx b/dash/src/components/WorkflowPanel.tsx new file mode 100644 index 00000000..df58e66a --- /dev/null +++ b/dash/src/components/WorkflowPanel.tsx @@ -0,0 +1,109 @@ +import type { ReactNode } from 'react' +import type { Current } from '@/lib/api' +import { fmtNum } from '@/lib/utils' + +// Median time-to-first-edit is milliseconds; render it compactly (sub-second up +// to hours) so a fast 0.8s and a slow 12m both read at a glance. +function fmtDuration(ms: number): string { + if (!isFinite(ms) || ms < 0) return '—' + if (ms < 1000) return `${Math.round(ms)}ms` + const s = ms / 1000 + if (s < 10) return `${s.toFixed(1)}s` + if (s < 60) return `${Math.round(s)}s` + const m = Math.floor(s / 60) + const rs = Math.round(s % 60) + if (m < 60) return rs ? `${m}m ${rs}s` : `${m}m` + const h = Math.floor(m / 60) + const rm = m % 60 + return rm ? `${h}h ${rm}m` : `${h}h` +} + +// The panel earns its place only when at least one signal carries real data. +// An older peer (or a period with no edit activity and no priced calls) leaves +// every field empty, and the whole panel is hidden so the dashboard renders as +// it did before this block existed. +export function hasWorkflowContent(c: Current): boolean { + const w = c.workflow + return ( + w?.correctionRate != null || + w?.medianTimeToFirstEditMs != null || + (w?.corrections ?? 0) > 0 || + c.pricingCoverage != null || + (c.topReworkedFiles?.length ?? 0) > 0 + ) +} + +function Row({ label, hint, value }: { label: string; hint?: string; value: ReactNode }) { + return ( +
+ + {label} + + {value} +
+ ) +} + +export function WorkflowPanel({ current }: { current: Current }) { + const w = current.workflow + const reworked = current.topReworkedFiles ?? [] + const coverage = current.pricingCoverage + + const correction = + w?.correctionRate == null ? ( + + ) : ( + <> + {Math.round(w.correctionRate * 100)}% + · {fmtNum(w.corrections)} + + ) + + return ( +
+
+ + — + ) : ( + {fmtDuration(w.medianTimeToFirstEditMs)} + ) + } + /> + {coverage != null && ( + {Math.round(coverage * 100)}%} + /> + )} +
+ + {reworked.length > 0 && ( +
+
Most reworked
+
+ {reworked.slice(0, 8).map((f) => ( +
+ + {f.path} + + + {fmtNum(f.edits)} edits · {fmtNum(f.sessions)} sess + +
+ ))} +
+
+ )} +
+ ) +} diff --git a/dash/src/lib/api.ts b/dash/src/lib/api.ts index dbeffa11..e7b5693d 100644 --- a/dash/src/lib/api.ts +++ b/dash/src/lib/api.ts @@ -56,6 +56,14 @@ export type Current = { skills: Array<{ name: string; turns: number; cost: number }> mcpServers: Array<{ name: string; calls: number }> modelEfficiency: Array<{ name: string; costPerEdit: number; oneShotRate: number }> + // Workflow-intelligence rollup for the period. Optional: an older peer's + // payload predates the block, and the Workflow panel hides when it is absent. + workflow?: { corrections: number; correctionRate: number | null; medianTimeToFirstEditMs: number | null } + // Files most reworked by edit-family calls (top 8), basenames only. + topReworkedFiles?: Array<{ path: string; sessions: number; edits: number }> + // Share (0-1) of cost-bearing calls that resolved a price. null when not + // computable; "unknown" must never render as 100% coverage. + pricingCoverage?: number | null localModelSavings: { totalUSD: number } retryTax: { totalUSD: number; retries: number } routingWaste: { totalSavingsUSD: number } @@ -138,6 +146,19 @@ function normalizePayload(p?: Payload): Payload | undefined { skills: c.skills ?? [], mcpServers: c.mcpServers ?? [], modelEfficiency: c.modelEfficiency ?? [], + workflow: c.workflow + ? { + corrections: c.workflow.corrections ?? 0, + correctionRate: c.workflow.correctionRate ?? null, + medianTimeToFirstEditMs: c.workflow.medianTimeToFirstEditMs ?? null, + } + : undefined, + topReworkedFiles: (c.topReworkedFiles ?? []).map((f) => ({ + path: f.path, + sessions: f.sessions ?? 0, + edits: f.edits ?? 0, + })), + pricingCoverage: c.pricingCoverage ?? null, localModelSavings: c.localModelSavings ?? { totalUSD: 0 }, retryTax: c.retryTax ?? { totalUSD: 0, retries: 0 }, routingWaste: c.routingWaste ?? { totalSavingsUSD: 0 },