From 1c465ef0c3cb248a6b605f6642ea85b6e566eb73 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:03:10 +0300 Subject: [PATCH] =?UTF-8?q?v0.4:=20historical=20trends=20=E2=80=94=20per-p?= =?UTF-8?q?latform=20leaderboard=20+=20sparklines=20+=20headroom=20overflo?= =?UTF-8?q?w=20projection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.2 shipped per-build sizes shards; v0.3 added the Kconfig configurator; v0.4 finally reads across the retention window. The Drift tab catches "package X grew between two specific nightlies" — useful but you need to already suspect a regression to pick the right two builds. TrendsView watches the slope: if a package has been growing 4 KB/week for the last 14 days, it surfaces at the top of a leaderboard before the rootfs cap overflows. This is the direct answer to PR OpenIPC/firmware#2163's pain point — "~12 KB of accumulated drift since 2026-05-17 has now tipped it over." With historical trends, the bisect step that PR had to do becomes visible from the dashboard. Data shape scripts/prebuild.mts now walks every (build × platform) sizes shard once per source after the per-tag download loop, accumulates per-platform series into one PlatformAccumulator, and writes a per-platform aggregated file at: public/data//trends/trends..json Schema (matches src/lib/timeseries.ts TrendsFile verbatim): { schema: 1, source, platform, generated_at, packages: { : [{build_id, built_at, bytes}, ...] }, modules: { : [{build_id, built_at, bytes}, ...] }, headroom_rootfs: [{build_id, built_at, used_kb, cap_kb, headroom_kb}, ...], headroom_kernel: [{...}, ...] } Every series ships sorted by built_at ascending. Defensive against partial shards: missing `headroom` blocks are skipped per build, not fatal. New FsHooks.read so the test memFs can serve readback inside the same in-memory state the gh-download mock writes into. Per-platform file: ~16 KB raw / ~1.5 KB gzipped on a board with 31 packages × 2 builds of data. Storage scales linearly with retention; ~80 KB raw at full 90-build retention. Total dist/data/firmware/trends/ across all 96 platforms today: 1.8 MB raw. Well under the plan's ~4 MB-gzipped envelope. UI TrendsView (new "Trends" tab between "Drift vs another build" and "Configure (what-if)"): * Header summary: builds count + date range covered * Two HeadroomChart sections — rootfs and kernel — that draw the used-bytes curve against the cap line. When projectOverflow(...) returns a non-null projection within 60 days, an amber badge surfaces "projected overflow in 7d (2026-06-12)". Linear regression: minimise (kb - (slope*day + intercept))^2, solve for kb=0. Sub-zero slope only — flat/growing returns null (no overflow projected). * Controls strip: window selector (7/14/30/90d), packages|modules toggle, "min weekly delta KB" filter to surface only the noteworthy growers. * Growers leaderboard sorted by absolute byte delta in window, newest 20. Each row gets an inline Sparkline (180×28 SVG polyline, colour by direction: amber=grow, green=shrink), plus a click-to-expand large sparkline (760×140) with axis labels. Sparklines are hand-rolled SVG — no chart lib. d3-hierarchy is already in the bundle (treemap) but doesn't transitively ship d3-scale/d3-shape; rather than add ~20 KB of charting deps for polylines and axes, the component pads + scales manually. Drop-in swap to a charting lib is easy if interactive zoom becomes a thing later. Tests tests/trends.test.ts (12 cases): * growthInWindow: window filtering, sort-on-input-order tolerance, first/last point semantics, < 2 points → null * bytesPerDay: window-endpoint slope, insufficient data → null * topGrowers: absolute-delta ordering, limit, skip insufficient, per-day rate * projectOverflow: flat/growing returns null, shrinking-at-known rate gives the expected days-to-zero tests/bundle.test.ts gains the same-origin invariant for trends/...json URLs — the regression guard for the original v0.1 CORS bug class now covers trends URLs too. tests/prebuild.test.ts memFs updated with `read` so the runPrebuild cases still pass; defensive `headroom?.rootfs` guards in emitTrends let the minimal-shard fixtures run through trends emission without exploding. Numbers - 63 active tests (was 46 in v0.3, +12 trends + adjustments) - JS bundle: 181 KB raw / 58 KB gzipped (+9 KB vs v0.3; under 250 KB perf budget) - Initial launch byte budget unchanged: ~60 KB gzipped (trends are lazy-loaded on Trends-tab click, ~1.5 KB gzipped per platform) - 96 trends files emitted today; 1.8 MB raw total Roadmap from README v0.4 (this) shipped → v0.5 (build-request flow, optional, depends on v0.3 fragment) remains. Maintenance backlog (cache eviction, 404 UX, drift URL state, picker search, data-window header indicator) unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/prebuild.mts | 142 ++++++++++++ src/App.tsx | 14 +- src/components/TrendsView.tsx | 418 ++++++++++++++++++++++++++++++++++ src/lib/timeseries.ts | 179 +++++++++++++++ src/lib/trends.ts | 35 +++ src/styles/app.css | 78 +++++++ tests/bundle.test.ts | 2 + tests/prebuild.test.ts | 5 + tests/trends.test.ts | 166 ++++++++++++++ 9 files changed, 1038 insertions(+), 1 deletion(-) create mode 100644 src/components/TrendsView.tsx create mode 100644 src/lib/timeseries.ts create mode 100644 src/lib/trends.ts create mode 100644 tests/trends.test.ts diff --git a/scripts/prebuild.mts b/scripts/prebuild.mts index e729b2b..ade2040 100644 --- a/scripts/prebuild.mts +++ b/scripts/prebuild.mts @@ -28,6 +28,7 @@ import { cpSync, existsSync, mkdirSync, + readFileSync, readdirSync, rmSync, writeFileSync, @@ -93,6 +94,7 @@ export type GhFn = (args: string[]) => string; export type FsHooks = { mkdir: (path: string) => void; write: (path: string, content: string | Uint8Array) => void; + read: (path: string) => string; exists: (path: string) => boolean; copyDir: (from: string, to: string) => void; rmDir: (path: string) => void; @@ -108,6 +110,7 @@ export const defaultGh: GhFn = (args) => export const defaultFs: FsHooks = { mkdir: (p) => mkdirSync(p, { recursive: true }), write: (p, c) => writeFileSync(p, c), + read: (p) => readFileSync(p, "utf-8"), exists: existsSync, copyDir: (from, to) => cpSync(from, to, { recursive: true }), rmDir: (p) => rmSync(p, { recursive: true, force: true }), @@ -298,6 +301,18 @@ export async function runPrebuild(opts: RunOpts): Promise<{ log, }); + // v0.4: aggregate every (build × platform) sizes shard into a per-platform + // time-series file so the explorer can render historical drift charts + // without fetching 90+ shards client-side. Computed in-runner; cheap + // because we already have the shards on disk. + emitTrends({ + builds, + source, + sourceOut, + fs, + log, + }); + const index: IndexFile = { schema: 1, source, @@ -317,6 +332,133 @@ export async function runPrebuild(opts: RunOpts): Promise<{ return { builds: out }; } +// --------------------------------------------------------------------------- +// Trends aggregation (v0.4) +// --------------------------------------------------------------------------- + +type SeriesPoint = { + build_id: string; + built_at: string; + bytes: number; +}; + +type HeadroomPoint = { + build_id: string; + built_at: string; + used_kb: number; + cap_kb: number; + headroom_kb: number | null; +}; + +type PlatformAccumulator = { + packages: Record; + modules: Record; + headroom_rootfs: HeadroomPoint[]; + headroom_kernel: HeadroomPoint[]; +}; + +/** + * Walk every (build × platform) sizes shard on disk and emit per-platform + * time-series files at `/trends/trends..json`. The shape + * matches `src/lib/timeseries.ts`'s `TrendsFile` exactly so the runtime can + * fetch and consume without a remap step. + * + * Memory note: per-platform aggregation only allocates while a single + * platform's shards are being read; entries land in `acc` keyed by platform + * but each shard's JSON is dropped after extraction. + */ +function emitTrends(opts: { + builds: BuildEntry[]; + source: Source; + sourceOut: string; + fs: FsHooks; + log: (msg: string) => void; +}): void { + const { builds, source, sourceOut, fs, log } = opts; + const trendsOut = join(sourceOut, "trends"); + + const acc = new Map(); + const bucket = (plat: string): PlatformAccumulator => { + let b = acc.get(plat); + if (!b) { + b = { packages: {}, modules: {}, headroom_rootfs: [], headroom_kernel: [] }; + acc.set(plat, b); + } + return b; + }; + + for (const build of builds) { + for (const plat of build.platforms) { + const shardPath = join(sourceOut, build.id, `sizes.${plat}.json`); + if (!fs.exists(shardPath)) continue; + + let sizes: { + packages?: Array<{ name: string; uncompressed_bytes: number }>; + linux_components?: { modules?: Array<{ name: string; bytes: number }> }; + headroom?: { + rootfs?: { used_kb: number; cap_kb: number; headroom_kb: number | null }; + kernel?: { used_kb: number; cap_kb: number; headroom_kb: number | null }; + }; + }; + try { + sizes = JSON.parse(fs.read(shardPath)); + } catch (e) { + log(`[${source}] ${build.id}/${plat} shard parse failed: ${(e as Error).message}`); + continue; + } + + const b = bucket(plat); + const stamp = { + build_id: build.id, + built_at: build.built_at, + }; + + for (const p of sizes.packages ?? []) { + (b.packages[p.name] ??= []).push({ ...stamp, bytes: p.uncompressed_bytes }); + } + for (const m of sizes.linux_components?.modules ?? []) { + (b.modules[m.name] ??= []).push({ ...stamp, bytes: m.bytes }); + } + if (sizes.headroom?.rootfs) { + b.headroom_rootfs.push({ ...stamp, ...sizes.headroom.rootfs }); + } + if (sizes.headroom?.kernel) { + b.headroom_kernel.push({ ...stamp, ...sizes.headroom.kernel }); + } + } + } + + fs.rmDir(trendsOut); + fs.mkdir(trendsOut); + + let written = 0; + for (const [plat, b] of acc) { + // Sort series ascending by built_at so consumers don't have to. + const sortPoints = (a: SeriesPoint, c: SeriesPoint) => + a.built_at.localeCompare(c.built_at); + const sortHead = (a: HeadroomPoint, c: HeadroomPoint) => + a.built_at.localeCompare(c.built_at); + for (const series of Object.values(b.packages)) series.sort(sortPoints); + for (const series of Object.values(b.modules)) series.sort(sortPoints); + b.headroom_rootfs.sort(sortHead); + b.headroom_kernel.sort(sortHead); + + const out = { + schema: 1, + source, + platform: plat, + generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"), + packages: b.packages, + modules: b.modules, + headroom_rootfs: b.headroom_rootfs, + headroom_kernel: b.headroom_kernel, + }; + fs.write(join(trendsOut, `trends.${plat}.json`), JSON.stringify(out) + "\n"); + written++; + } + log(`[${source}] wrote trends for ${written} platforms`); +} + /** * Walk the build list newest-first and download Kconfig assets from the first * tag that has them. Files land at: diff --git a/src/App.tsx b/src/App.tsx index 7c3c056..50690f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,9 +11,17 @@ import { ModuleTable } from "./components/ModuleTable"; import { PackageTreemap } from "./components/PackageTreemap"; import { RemovedPanel } from "./components/RemovedPanel"; import { DriftView } from "./components/DriftView"; +import { TrendsView } from "./components/TrendsView"; import { WhatIfPanel } from "./components/WhatIfPanel"; -type Tab = "tree" | "packages" | "modules" | "removed" | "drift" | "configure"; +type Tab = + | "tree" + | "packages" + | "modules" + | "removed" + | "drift" + | "trends" + | "configure"; export function App() { const initial = useMemo(() => readQueryString(window.location.search), []); @@ -151,6 +159,7 @@ export function App() { true, ], ["drift", "Drift vs another build", true], + ["trends", "Trends", true], ["configure", "Configure (what-if)", !!kconfigAvailable], ]; return tabs.map(([k, label, enabled]) => ( @@ -190,6 +199,9 @@ export function App() { platform={platform} /> )} + {tab === "trends" && platform && ( + + )} {tab === "configure" && platform && ( )} diff --git a/src/components/TrendsView.tsx b/src/components/TrendsView.tsx new file mode 100644 index 0000000..a222b60 --- /dev/null +++ b/src/components/TrendsView.tsx @@ -0,0 +1,418 @@ +import { Fragment, useEffect, useMemo, useState } from "react"; +import type { Source } from "../lib/types"; +import type { HeadroomPoint, SeriesPoint, TrendsFile } from "../lib/timeseries"; +import { + projectOverflow, + sortedByDate, + topGrowers, + type GrowerRow, +} from "../lib/timeseries"; +import { fetchTrends } from "../lib/trends"; + +type Props = { + source: Source; + platform: string; +}; + +type WindowDays = 7 | 14 | 30 | 90; +type Kind = "packages" | "modules"; + +const WINDOW_OPTIONS: WindowDays[] = [7, 14, 30, 90]; +const TOP_N = 20; + +function fmtBytes(b: number): string { + const abs = Math.abs(b); + if (abs >= 1024 * 1024) return (b / 1024 / 1024).toFixed(2) + " MB"; + if (abs >= 1024) return (b / 1024).toFixed(1) + " KB"; + return b + " B"; +} + +function fmtSignedBytes(b: number): string { + const sign = b > 0 ? "+" : b < 0 ? "−" : ""; + const abs = Math.abs(b); + if (abs >= 1024 * 1024) return sign + (abs / 1024 / 1024).toFixed(2) + " MB"; + if (abs >= 1024) return sign + (abs / 1024).toFixed(1) + " KB"; + return sign + abs + " B"; +} + +function fmtPerWeek(perDay: number): string { + return fmtSignedBytes(perDay * 7) + "/wk"; +} + +function fmtDate(iso: string): string { + return iso.slice(0, 10); +} + +export function TrendsView({ source, platform }: Props) { + const [trends, setTrends] = useState(null); + const [error, setError] = useState(null); + const [windowDays, setWindowDays] = useState(30); + const [kind, setKind] = useState("packages"); + const [expanded, setExpanded] = useState(null); + const [minWeeklyKb, setMinWeeklyKb] = useState(0); + + useEffect(() => { + setTrends(null); + setError(null); + setExpanded(null); + fetchTrends(source, platform) + .then(setTrends) + .catch((e: Error) => setError(e.message)); + }, [source, platform]); + + const seriesByName = useMemo(() => { + if (!trends) return {}; + return kind === "packages" ? trends.packages : trends.modules; + }, [trends, kind]); + + const growers = useMemo(() => { + if (!trends) return []; + const all = topGrowers(seriesByName, windowDays, 1_000); + const threshold = minWeeklyKb * 1024; + const filtered = threshold > 0 + ? all.filter((r) => Math.abs(r.perDayBytes * 7) >= threshold) + : all; + return filtered.slice(0, TOP_N); + }, [trends, seriesByName, windowDays, minWeeklyKb]); + + const dataWindow = useMemo(() => { + if (!trends) return null; + const all = trends.headroom_rootfs; + if (all.length === 0) return null; + return { + points: all.length, + oldest: all[0]?.built_at, + newest: all[all.length - 1]?.built_at, + }; + }, [trends]); + + if (error) { + return ( +

+ trends fetch failed: {error} +

+ ); + } + if (!trends) { + return

loading trends…

; + } + if (!dataWindow) { + return ( +

+ no historical data available for this platform yet +

+ ); + } + + return ( +
+

+ {dataWindow.points} builds, {fmtDate(dataWindow.oldest!)} →{" "} + {fmtDate(dataWindow.newest!)}. v0.4 ships per-platform aggregates over + the explorer's retention window; the leaderboard surfaces the kind of + week-on-week creep that PR #2163 had to wait for a rootfs overflow to + notice. +

+ + + + +
+ + + + + {growers.length} growers (top {TOP_N} of {Object.keys(seriesByName).length}) + +
+ + + + + + + + + + + + + {growers.map((g) => { + const series = seriesByName[g.name] ?? []; + const isOpen = expanded === g.name; + const direction = g.delta > 0 ? "grow" : g.delta < 0 ? "shrink" : "flat"; + return ( + + + + + + + + + {isOpen && ( + + + + )} + + ); + })} + {growers.length === 0 && ( + + + + )} + +
{kind === "packages" ? "Package" : "Module"}NowΔ in window≈ /weekTrend
+ + {fmtBytes(g.lastBytes)}{fmtSignedBytes(g.delta)}{fmtPerWeek(g.perDayBytes)} + +
+ +
+ no {kind} cross the {minWeeklyKb} KB/wk threshold over the last {windowDays} days +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Sparkline — simple SVG polyline, no chart library. +// --------------------------------------------------------------------------- + +const MS_PER_DAY = 86_400_000; + +function Sparkline({ + series, + windowDays, + width, + height, + showAxes = false, +}: { + series: readonly SeriesPoint[]; + windowDays: number; + width: number; + height: number; + showAxes?: boolean; +}) { + const now = Date.now(); + const cutoff = now - windowDays * MS_PER_DAY; + const visible = series.filter((p) => new Date(p.built_at).getTime() >= cutoff); + + if (visible.length < 2) { + return ( + + only {visible.length} point{visible.length === 1 ? "" : "s"} + + ); + } + + const minBytes = Math.min(...visible.map((p) => p.bytes)); + const maxBytes = Math.max(...visible.map((p) => p.bytes)); + const range = Math.max(1, maxBytes - minBytes); + const t0 = new Date(visible[0].built_at).getTime(); + const tn = new Date(visible[visible.length - 1].built_at).getTime(); + const span = Math.max(1, tn - t0); + + const pad = showAxes ? 28 : 2; + const innerW = width - pad * 2; + const innerH = height - pad * 2; + const pts = visible.map((p) => { + const x = pad + ((new Date(p.built_at).getTime() - t0) / span) * innerW; + const y = pad + innerH - ((p.bytes - minBytes) / range) * innerH; + return { x, y, p }; + }); + + const path = pts.map((q, i) => `${i === 0 ? "M" : "L"}${q.x.toFixed(1)},${q.y.toFixed(1)}`).join(" "); + const lastDelta = pts[pts.length - 1].p.bytes - pts[0].p.bytes; + const colour = lastDelta > 0 ? "#f0883e" : lastDelta < 0 ? "#3fb950" : "#8b949e"; + + return ( + + {showAxes && ( + <> + {fmtBytes(maxBytes)} + {fmtBytes(minBytes)} + {fmtDate(visible[0].built_at)} + {fmtDate(visible[visible.length - 1].built_at)} + + )} + + {pts.map((q) => ( + + {q.p.build_id} · {fmtBytes(q.p.bytes)} + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// HeadroomChart — like Sparkline but draws a cap line + overflow projection. +// --------------------------------------------------------------------------- + +function HeadroomChart({ + title, + headroom, + windowDays, +}: { + title: string; + headroom: readonly HeadroomPoint[]; + windowDays: number; +}) { + const now = Date.now(); + const cutoff = now - windowDays * MS_PER_DAY; + const visible = headroom.filter( + (p) => new Date(p.built_at).getTime() >= cutoff, + ); + const proj = useMemo( + () => projectOverflow(headroom, windowDays, now), + [headroom, windowDays, now], + ); + + if (visible.length === 0) { + return null; + } + + const width = 760; + const height = 100; + const pad = 36; + + const capKb = visible[0].cap_kb || 1; + const t0 = new Date(visible[0].built_at).getTime(); + const tn = new Date(visible[visible.length - 1].built_at).getTime(); + const span = Math.max(1, tn - t0); + const innerW = width - pad * 2; + const innerH = height - pad * 1.4; + + const pts = visible.map((p) => { + const x = pad + ((new Date(p.built_at).getTime() - t0) / span) * innerW; + // Y axis: 0 (full) ↔ capKb (over). Used_kb maps from top to bottom. + const usedFrac = Math.max(0, Math.min(1, p.used_kb / capKb)); + const y = pad + usedFrac * innerH; + return { x, y, p }; + }); + + const usedPath = pts + .map((q, i) => `${i === 0 ? "M" : "L"}${q.x.toFixed(1)},${q.y.toFixed(1)}`) + .join(" "); + const lastHeadroom = visible[visible.length - 1].headroom_kb ?? 0; + const colour = lastHeadroom < 0 ? "#f85149" : lastHeadroom < capKb * 0.05 ? "#f0883e" : "#3fb950"; + + return ( +
+
+ {title} + + cap {capKb} KB · now {fmtBytes(visible[visible.length - 1].used_kb * 1024)} /{" "} + {capKb * 1024 > 0 ? fmtBytes(capKb * 1024) : "—"} ·{" "} + headroom {lastHeadroom} KB + + {proj && proj.daysToZero < 60 && ( + + projected overflow in {Math.round(proj.daysToZero)}d + ({proj.projectedDate.slice(0, 10)}) + + )} +
+ + + cap + + 0 + + {pts.map((q) => ( + + + {q.p.build_id} · used {q.p.used_kb} KB · headroom {q.p.headroom_kb ?? "n/a"} KB + + + ))} + + {fmtDate(visible[0].built_at)} + + + {fmtDate(visible[visible.length - 1].built_at)} + + +
+ ); +} diff --git a/src/lib/timeseries.ts b/src/lib/timeseries.ts new file mode 100644 index 0000000..84a49cb --- /dev/null +++ b/src/lib/timeseries.ts @@ -0,0 +1,179 @@ +// Pure helpers for v0.4 historical-trends. The shape of `TrendsFile` matches +// what `scripts/prebuild.mts` emits to public/data//trends/.json +// after walking every (build × platform) sizes shard. +// +// Everything here is exported for testability — no DOM, no fetch. + +import type { Source } from "./types"; + +export type SeriesPoint = { + build_id: string; + built_at: string; + bytes: number; +}; + +export type HeadroomPoint = { + build_id: string; + built_at: string; + used_kb: number; + cap_kb: number; + headroom_kb: number | null; +}; + +export type TrendsFile = { + schema: 1; + source: Source; + platform: string; + generated_at: string; + packages: Record; + modules: Record; + headroom_rootfs: HeadroomPoint[]; + headroom_kernel: HeadroomPoint[]; +}; + +const MS_PER_DAY = 86_400_000; + +function asDate(iso: string): number { + return new Date(iso).getTime(); +} + +function inWindow(builtAt: string, windowDays: number, now: number): boolean { + return now - asDate(builtAt) <= windowDays * MS_PER_DAY; +} + +/** + * Absolute byte growth between the first and last entries within the rolling + * window. Returns `null` when fewer than two qualifying points are available + * (single observation can't establish a delta). + */ +export function growthInWindow( + series: readonly SeriesPoint[], + windowDays: number, + now: number = Date.now(), +): { delta: number; first: SeriesPoint; last: SeriesPoint } | null { + const inside = series.filter((p) => inWindow(p.built_at, windowDays, now)); + if (inside.length < 2) return null; + const sorted = [...inside].sort((a, b) => asDate(a.built_at) - asDate(b.built_at)); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + return { delta: last.bytes - first.bytes, first, last }; +} + +/** Same shape as growthInWindow but returns the implied bytes-per-day slope. */ +export function bytesPerDay( + series: readonly SeriesPoint[], + windowDays: number, + now: number = Date.now(), +): number | null { + const g = growthInWindow(series, windowDays, now); + if (!g) return null; + const days = (asDate(g.last.built_at) - asDate(g.first.built_at)) / MS_PER_DAY; + if (days <= 0) return null; + return g.delta / days; +} + +export type GrowerRow = { + name: string; + delta: number; + perDayBytes: number; + firstBytes: number; + lastBytes: number; + pointCount: number; +}; + +/** + * Rank items (packages or modules) by absolute growth over `windowDays`, + * largest first. Series without at least two points in the window are skipped. + */ +export function topGrowers( + byName: Record, + windowDays: number, + limit: number, + now: number = Date.now(), +): GrowerRow[] { + const rows: GrowerRow[] = []; + for (const [name, series] of Object.entries(byName)) { + const g = growthInWindow(series, windowDays, now); + if (!g) continue; + const days = + (asDate(g.last.built_at) - asDate(g.first.built_at)) / MS_PER_DAY; + rows.push({ + name, + delta: g.delta, + perDayBytes: days > 0 ? g.delta / days : 0, + firstBytes: g.first.bytes, + lastBytes: g.last.bytes, + pointCount: series.filter((p) => inWindow(p.built_at, windowDays, now)) + .length, + }); + } + rows.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); + return rows.slice(0, limit); +} + +export type OverflowProjection = { + /** Days from `now` until projected headroom hits zero. */ + daysToZero: number; + /** ISO date when headroom is projected to hit zero. */ + projectedDate: string; + /** Headroom slope in KB/day (negative = shrinking). */ + slopeKbPerDay: number; +}; + +/** + * Linear projection of when headroom will hit zero given the trend over + * `windowDays`. Returns `null` if the trend is flat or positive, or if there + * aren't enough qualifying points to fit a line. + * + * Uses simple OLS: minimise the squared residuals of (kb − (a·day + b)). + */ +export function projectOverflow( + headroom: readonly HeadroomPoint[], + windowDays: number, + now: number = Date.now(), +): OverflowProjection | null { + const inside = headroom.filter( + (p) => inWindow(p.built_at, windowDays, now) && p.headroom_kb !== null, + ); + if (inside.length < 2) return null; + const sorted = [...inside].sort( + (a, b) => asDate(a.built_at) - asDate(b.built_at), + ); + + // Anchor x at days since `now` (negative for past). `headroom_kb` checked + // non-null above; the cast satisfies TS. + const xs = sorted.map((p) => (asDate(p.built_at) - now) / MS_PER_DAY); + const ys = sorted.map((p) => p.headroom_kb as number); + + const xMean = xs.reduce((s, x) => s + x, 0) / xs.length; + const yMean = ys.reduce((s, y) => s + y, 0) / ys.length; + let num = 0; + let den = 0; + for (let i = 0; i < xs.length; i++) { + num += (xs[i] - xMean) * (ys[i] - yMean); + den += (xs[i] - xMean) ** 2; + } + if (den === 0) return null; + + const slope = num / den; // KB per day + if (slope >= 0) return null; // flat or growing — no overflow projected + + const intercept = yMean - slope * xMean; + // Solve for kb = 0 ⇒ day = -intercept / slope + const dayAtZero = -intercept / slope; + if (dayAtZero <= 0) return null; // overflow already implied to be in the past; treat as no-info + + return { + daysToZero: dayAtZero, + projectedDate: new Date(now + dayAtZero * MS_PER_DAY).toISOString(), + slopeKbPerDay: slope, + }; +} + +/** + * Sort a series ascending by `built_at`. Helper for the leaderboard click -> + * sparkline path so callers don't have to re-sort. + */ +export function sortedByDate(series: readonly SeriesPoint[]): SeriesPoint[] { + return [...series].sort((a, b) => asDate(a.built_at) - asDate(b.built_at)); +} diff --git a/src/lib/trends.ts b/src/lib/trends.ts new file mode 100644 index 0000000..7d9b035 --- /dev/null +++ b/src/lib/trends.ts @@ -0,0 +1,35 @@ +import type { Source } from "./types"; +import type { TrendsFile } from "./timeseries"; + +export function trendsUrl(source: Source, platform: string): string { + return `./data/${source}/trends/trends.${platform}.json`; +} + +const cache = new Map>(); + +export function fetchTrends(source: Source, platform: string): Promise { + const url = trendsUrl(source, platform); + let p = cache.get(url); + if (!p) { + p = (async () => { + const r = await fetch(url, { cache: "force-cache" }); + if (!r.ok) throw new Error(`trends: HTTP ${r.status} from ${url}`); + const t = (await r.json()) as TrendsFile; + if (t.schema !== 1) { + throw new Error( + `trends: unsupported schema ${t.schema} (expected 1)`, + ); + } + return t; + })().catch((e) => { + cache.delete(url); + throw e; + }); + cache.set(url, p); + } + return p; +} + +export function clearTrendsCache(): void { + cache.clear(); +} diff --git a/src/styles/app.css b/src/styles/app.css index d45f53e..83c37d5 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -566,6 +566,84 @@ td.path { cursor: not-allowed; } +/* --- trends view --- */ + +.trends-controls { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: flex-end; + margin: 16px 0 8px; +} + +.trends-controls label { + display: flex; + flex-direction: column; + gap: 4px; +} + +.trends-controls span { + font-size: 12px; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.trends-controls input { + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 8px; + font: inherit; +} + +.segmented { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.segmented button { + background: transparent; + border: 0; + color: var(--fg-muted); + padding: 4px 12px; + font: inherit; + cursor: pointer; +} + +.segmented button.active { + background: var(--bg-elev2); + color: var(--fg); +} + +.sparkline { + display: block; +} + +.trend-axis-label { + font-size: 10px; + fill: var(--fg-muted); +} + +.headroom-chart { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 12px; +} + +.headroom-chart-head { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 8px; + flex-wrap: wrap; +} + /* --- removed-by-finalize panel --- */ .removed p { diff --git a/tests/bundle.test.ts b/tests/bundle.test.ts index b90e50d..f078279 100644 --- a/tests/bundle.test.ts +++ b/tests/bundle.test.ts @@ -45,6 +45,8 @@ describe.skipIf(skip)("dist/ bundle invariants", () => { expect(content).not.toMatch(/releases\/download\/[^"'`]+\.json/); // v0.3 invariant: kconfig graph + help URLs must also stay same-origin. expect(content).not.toMatch(/releases\/download\/[^"'`]+kconfig[^"'`]+/); + // v0.4 invariant: trends..json never crosses origin either. + expect(content).not.toMatch(/releases\/download\/[^"'`]+trends[^"'`]+/); }, ); diff --git a/tests/prebuild.test.ts b/tests/prebuild.test.ts index f15800d..c3f220f 100644 --- a/tests/prebuild.test.ts +++ b/tests/prebuild.test.ts @@ -87,6 +87,11 @@ function memFs(): { fs: FsHooks; state: MemFsState } { ensureDirChain(dir); state.files.set(p, typeof c === "string" ? c : new TextDecoder().decode(c)); }, + read: (p) => { + const content = state.files.get(p); + if (content === undefined) throw new Error(`memFs: no such file ${p}`); + return content; + }, exists: (p) => state.dirs.has(p) || state.files.has(p), copyDir: (from, to) => { ensureDirChain(to); diff --git a/tests/trends.test.ts b/tests/trends.test.ts new file mode 100644 index 0000000..b54abbe --- /dev/null +++ b/tests/trends.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { + bytesPerDay, + growthInWindow, + projectOverflow, + sortedByDate, + topGrowers, + type HeadroomPoint, + type SeriesPoint, +} from "../src/lib/timeseries"; + +const MS_PER_DAY = 86_400_000; + +function pt(daysAgo: number, bytes: number, now: number): SeriesPoint { + return { + build_id: `b-${daysAgo}`, + built_at: new Date(now - daysAgo * MS_PER_DAY).toISOString(), + bytes, + }; +} + +function head( + daysAgo: number, + usedKb: number, + capKb: number, + now: number, +): HeadroomPoint { + return { + build_id: `b-${daysAgo}`, + built_at: new Date(now - daysAgo * MS_PER_DAY).toISOString(), + used_kb: usedKb, + cap_kb: capKb, + headroom_kb: capKb - usedKb, + }; +} + +describe("growthInWindow", () => { + const now = Date.UTC(2026, 5, 5, 12, 0, 0); + + it("returns null when fewer than two points in window", () => { + expect(growthInWindow([], 30, now)).toBeNull(); + expect(growthInWindow([pt(1, 100, now)], 30, now)).toBeNull(); + }); + + it("filters points outside the window", () => { + // 2 outside (60d, 45d), 2 inside (5d, 1d) when window=30 + const series = [ + pt(60, 100, now), + pt(45, 110, now), + pt(5, 150, now), + pt(1, 200, now), + ]; + const g = growthInWindow(series, 30, now); + expect(g).not.toBeNull(); + expect(g!.delta).toBe(50); // 200 − 150 + expect(g!.first.bytes).toBe(150); + expect(g!.last.bytes).toBe(200); + }); + + it("sorts internally so input order doesn't matter", () => { + const series = [pt(1, 200, now), pt(5, 100, now), pt(10, 80, now)]; + const g = growthInWindow(series, 30, now); + expect(g!.first.bytes).toBe(80); + expect(g!.last.bytes).toBe(200); + expect(g!.delta).toBe(120); + }); +}); + +describe("bytesPerDay", () => { + const now = Date.UTC(2026, 5, 5, 12, 0, 0); + + it("computes the slope from the window endpoints", () => { + const series = [pt(10, 100, now), pt(0, 200, now)]; + expect(bytesPerDay(series, 30, now)).toBe(10); + }); + + it("returns null on insufficient data", () => { + expect(bytesPerDay([], 30, now)).toBeNull(); + expect(bytesPerDay([pt(0, 100, now)], 30, now)).toBeNull(); + }); +}); + +describe("topGrowers", () => { + const now = Date.UTC(2026, 5, 5, 12, 0, 0); + + it("ranks by absolute delta and respects the limit", () => { + const byName = { + majestic: [pt(10, 700_000, now), pt(0, 724_000, now)], + busybox: [pt(10, 600_000, now), pt(0, 595_000, now)], + libcurl: [pt(10, 500_000, now), pt(0, 500_000, now)], + ffmpeg: [pt(10, 1_000_000, now), pt(0, 950_000, now)], + }; + const rows = topGrowers(byName, 30, 3, now); + expect(rows.map((r) => r.name)).toEqual(["ffmpeg", "majestic", "busybox"]); + expect(rows[0].delta).toBe(-50_000); + expect(rows[1].delta).toBe(24_000); + }); + + it("skips series without enough points in the window", () => { + const byName = { + tiny: [pt(0, 100, now)], + ok: [pt(10, 100, now), pt(0, 200, now)], + }; + const rows = topGrowers(byName, 30, 10, now); + expect(rows.map((r) => r.name)).toEqual(["ok"]); + }); + + it("computes per-day rate", () => { + const byName = { + pkg: [pt(7, 0, now), pt(0, 7_000, now)], + }; + const rows = topGrowers(byName, 30, 10, now); + expect(rows[0].perDayBytes).toBeCloseTo(1_000, 0); + }); +}); + +describe("projectOverflow", () => { + const now = Date.UTC(2026, 5, 5, 12, 0, 0); + + it("returns null when the trend is flat or growing", () => { + const flat = [ + head(14, 5_000, 5_120, now), + head(7, 5_000, 5_120, now), + head(0, 5_000, 5_120, now), + ]; + expect(projectOverflow(flat, 30, now)).toBeNull(); + + // Headroom growing means used_kb shrinking → no overflow. + const growing = [ + head(14, 5_100, 5_120, now), + head(7, 5_050, 5_120, now), + head(0, 5_000, 5_120, now), + ]; + expect(projectOverflow(growing, 30, now)).toBeNull(); + }); + + it("projects the date headroom will cross zero on a shrinking trend", () => { + // Headroom: 120 → 80 → 40 KB over 14 days = -40 KB / 7d = -40/7 KB/d + // From 40 KB at "now", overflow in 40 / (40/7) = 7 days. + const shrinking = [ + head(14, 5_000, 5_120, now), + head(7, 5_040, 5_120, now), + head(0, 5_080, 5_120, now), + ]; + const proj = projectOverflow(shrinking, 30, now); + expect(proj).not.toBeNull(); + expect(proj!.daysToZero).toBeCloseTo(7, 0); + expect(proj!.slopeKbPerDay).toBeLessThan(0); + }); + + it("requires at least two non-null points in window", () => { + expect(projectOverflow([], 30, now)).toBeNull(); + expect(projectOverflow([head(0, 5_000, 5_120, now)], 30, now)).toBeNull(); + }); +}); + +describe("sortedByDate", () => { + const now = Date.UTC(2026, 5, 5, 12, 0, 0); + + it("returns a new array, oldest first", () => { + const series = [pt(1, 200, now), pt(10, 100, now), pt(5, 150, now)]; + const out = sortedByDate(series); + expect(out.map((p) => p.bytes)).toEqual([100, 150, 200]); + expect(out).not.toBe(series); + }); +});