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:
-
+
+ {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 },