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 },