Index: frontend/src/utils/timeSeries.js
===================================================================
--- frontend/src/utils/timeSeries.js	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
+++ frontend/src/utils/timeSeries.js	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
@@ -0,0 +1,116 @@
+// Utilities for bucketing time-series data and computing percentage changes.
+
+function startOfDay(d) {
+  return new Date(d.getFullYear(), d.getMonth(), d.getDate());
+}
+
+function startOfMonth(d) {
+  return new Date(d.getFullYear(), d.getMonth(), 1);
+}
+
+function startOfYear(d) {
+  return new Date(d.getFullYear(), 0, 1);
+}
+
+// ISO week start (Monday)
+function startOfISOWeek(d) {
+  const date = startOfDay(d);
+  const day = date.getDay(); // 0..6 (Sun..Sat)
+  const diff = (day === 0 ? -6 : 1) - day;
+  date.setDate(date.getDate() + diff);
+  return startOfDay(date);
+}
+
+function bucketKey(date, granularity) {
+  const d = new Date(date);
+  if (Number.isNaN(d.getTime())) return null;
+
+  let s;
+  switch (granularity) {
+    case "daily":
+      s = startOfDay(d);
+      break;
+    case "weekly":
+      s = startOfISOWeek(d);
+      break;
+    case "monthly":
+      s = startOfMonth(d);
+      break;
+    case "yearly":
+      s = startOfYear(d);
+      break;
+    default:
+      s = startOfDay(d);
+  }
+
+  return s.getTime();
+}
+
+export function sumByTimeBucket(items, granularity, getDate, getValue) {
+  const map = new Map();
+
+  for (const it of items ?? []) {
+    const rawDate = getDate(it);
+    const k = bucketKey(rawDate, granularity);
+    if (k == null) continue;
+
+    const v = Number(getValue(it) ?? 0);
+    if (!Number.isFinite(v)) continue;
+
+    map.set(k, (map.get(k) ?? 0) + v);
+  }
+
+  const points = Array.from(map.entries())
+    .sort((a, b) => a[0] - b[0])
+    .map(([ts, value]) => ({ ts, value }));
+
+  return points;
+}
+
+export function percentChangeSeries(points) {
+  // Output: [{ ts, value: pctChange, base: sumValue }]
+  const out = [];
+  let prev = null;
+
+  for (const p of points ?? []) {
+    const curr = Number(p.value ?? 0);
+    let pct = 0;
+
+    if (prev == null) {
+      pct = 0;
+    } else if (prev === 0) {
+      // Avoid exploding infinity when previous is 0.
+      // Define: if curr is also 0 -> 0%, else -> 100%.
+      pct = curr === 0 ? 0 : 100;
+    } else {
+      pct = ((curr - prev) / prev) * 100;
+    }
+
+    out.push({ ts: p.ts, value: pct, base: curr });
+    prev = curr;
+  }
+
+  return out;
+}
+
+export function formatBucketLabel(ts, granularity) {
+  const d = new Date(ts);
+  if (Number.isNaN(d.getTime())) return "—";
+
+  const optsDay = { month: "short", day: "2-digit" };
+  const optsMonth = { year: "numeric", month: "short" };
+
+  switch (granularity) {
+    case "daily":
+      return d.toLocaleDateString(undefined, optsDay);
+    case "weekly":
+      return `Wk of ${d.toLocaleDateString(undefined, optsDay)}`;
+    case "monthly":
+      return d.toLocaleDateString(undefined, optsMonth);
+    case "yearly":
+      return String(d.getFullYear());
+    default:
+      return d.toLocaleDateString();
+  }
+}
+
