Two unrelated fixes landed in the same PR this time, one about the analytics dashboard, one about how long a PR waits on CI. Neither depends on the other. They just happened to get done in the same sitting.
Why the dashboard’s charts got rebuilt from scratch
The analytics dashboard has existed since Part 12, rendering the cost and latency aggregates through recharts. It worked, but it looked like a default: flat stacked bars with no hover feedback, no way to isolate one series in a legend, nothing that told you which segment you were looking at without squinting at color.
Recharts is a big, prop-heavy library built to cover every chart shape a consumer might want. That generality is exactly what made small, specific interactions expensive to bolt on: a crosshair tooltip that tracks the cursor across a multi-line chart, or a legend where clicking an entry isolates just that series, aren’t things the library hands you for free. Getting them right meant fighting the abstraction more than using it.
So I dropped recharts and wrote the three chart shapes the dashboard actually needs as plain SVG components: a stacked bar chart, a multi-line chart, and a latency-bars chart, plus two small shared pieces, ChartTooltip and ChartLegend.
The trade is honest: this is more code I own and maintain instead of a dependency’s problem. It was worth it here because the interactions I wanted, a crosshair, a click-to-isolate legend, consistent rounded bar caps, are a handful of SVG attributes when you’re drawing the shape yourself, and were fighting recharts’ component tree otherwise. The dependency drops out of web/package.json entirely, and Analytics.tsx came out smaller than before (641 lines versus 737) despite the new chart components living right beside it, because composing a few purpose-built SVG pieces turned out simpler than threading recharts’ props through this dashboard’s specific needs.
The chart pieces follow a fixed set of mark specs so the three chart types read as one system instead of three separate experiments: 4px rounded caps on the data ends of bars, a 2px gap between stacked segments so adjacent series stay visually separate, and a categorical palette that’s explicitly ordered and never cycles past its eighth color, chosen to stay distinguishable for color-vision-deficient viewers:
// web/src/components/charts/palette.ts
// Validated categorical palette: fixed hue order, never cycled past
// slot 8, CVD-safe adjacent pairs in both light and dark.
export const palette = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948']
The tooltip is a single shared component fed by whichever chart is active, positioned off the cursor’s own coordinates so it reads as a crosshair rather than a fixed-position label:
// web/src/components/charts/ChartTooltip.tsx
<div
className="pointer-events-none absolute z-20 min-w-[120px] rounded-md border border-border bg-popover px-2.5 py-2 text-xs shadow-lg"
style={{ left: state.x + 12, top: state.y - 8 }}
>
The legend does double duty as a filter. Clicking an entry doesn’t just dim it, it toggles that series out of the chart entirely, so a busy stacked bar or a multi-line chart with eight series can be narrowed down to the one you’re actually comparing:
// web/src/components/charts/ChartLegend.tsx
<span
key={g}
role="button"
tabIndex={0}
onClick={() => onToggle(g)}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggle(g)}
className="flex cursor-pointer items-center gap-1.5 select-none"
>
Nothing about the dashboard’s data changed. Same five ledger aggregate endpoints from Part 12, same numbers. Only the rendering layer moved.
Why CI was paying for work it didn’t need
Separately: every pull request in this repo ran the entire CI suite regardless of what the diff actually touched. A one-line change to Analytics.tsx still queued go-test, go-integration, lint, and four separate Go image builds, none of which had anything to do with the change, alongside the web job that did.
The fix looks simple on the surface: add a changes job using dorny/paths-filter to detect which paths a PR touches, then gate every other job behind an if: that reads its output. A Go-only diff skips web; a web-only diff skips go-test, go-integration, and lint.
The trap is in what happens to the merge gate once jobs start skipping. The gate (ci-ok) had been an allowlist requiring every job to report exactly success, precisely because an earlier incident (documented back in the foundation milestone) showed a blocklist letting a skipped result through during a GitHub Actions outage and auto-merging a red run. Turn on path filtering naively against that same allowlist, and a job correctly skipped because its path wasn’t touched now reports skipped, which fails the gate for a completely unrelated reason. The naive fix either reopens the old hole (allow skipped again, everywhere) or blocks every PR on jobs that had no reason to run.
The actual fix keeps both properties at once: ci-ok now checks each job against the same changes outputs that gated it. A job is expected to be skipped if its path wasn’t touched, and that passes. A job whose path WAS touched has to report success, full stop, skipped included:
# .github/workflows/ci.yml
check() {
local job="$1" relevant="$2" result="$3"
if [ "$relevant" = "true" ]; then
[ "$result" = "success" ] || { echo "gate failed: $job result '$result' (relevant)"; exit 1; }
else
case "$result" in
success|skipped) ;;
*) echo "gate failed: $job result '$result' (not relevant, but not skipped either)"; exit 1 ;;
esac
fi
}
A job can no longer pass the gate just by never running. It has to be genuinely irrelevant to the diff, per the same filter every other job trusts, or it has to actually succeed.
The images job stayed a single job with an if: on each build step instead of a job-level condition, because one PR can touch Go-only paths, web-only paths, or both, and the four legs of that build matrix (web plus three Go services) need to skip independently of each other rather than all-or-nothing.
The one wrinkle: changes itself failed on real PR runs, though not on local pushes, the first time it shipped. paths-filter falls back to the GitHub API to list changed files when it can’t diff locally, and the workflow’s default contents: read permission blocked that API call outright (“Resource not accessible by integration”). The fix scopes an extra pull-requests: read permission to just the changes job, not the whole workflow, which is the smaller grant that still lets that one job do its job.
Where this leaves things
The dashboard finally feels like something I built on purpose instead of something a library defaulted me into, and a frontend-only PR no longer sits around waiting on four Go image builds that were never going to touch it. Small milestone, but it’s the kind that makes every PR after it a little less annoying to wait on.