Index: frontend/package-lock.json
===================================================================
--- frontend/package-lock.json	(revision e9a0543bbb622a2b343d1f5fe3b2df7ddb6a0bd3)
+++ frontend/package-lock.json	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
@@ -15,7 +15,9 @@
         "@relume_io/relume-ui": "^1.3.1",
         "@tailwindcss/vite": "^4.1.18",
+        "apexcharts": "^5.10.6",
         "axios": "^1.13.4",
         "daisyui": "^5.5.18",
         "react": "^19.2.0",
+        "react-apexcharts": "^2.1.0",
         "react-dom": "^19.2.0",
         "react-icons": "^5.5.0",
@@ -5724,4 +5726,10 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
+    },
+    "node_modules/apexcharts": {
+      "version": "5.10.6",
+      "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.6.tgz",
+      "integrity": "sha512-FJQGbso3iRuOwUYnj0yUhkWeKeJE6aboVol+ae09lsc+lbLMWZqSRbrAWVa/qishLiaeG2icxdvmVkm+9n6kOQ==",
+      "license": "SEE LICENSE IN LICENSE"
     },
     "node_modules/argparse": {
@@ -6911,5 +6919,4 @@
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
-      "dev": true,
       "license": "MIT"
     },
@@ -7280,4 +7287,16 @@
       "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
       "license": "MIT"
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
     },
     "node_modules/lru-cache": {
@@ -9353,4 +9372,13 @@
       "license": "ISC"
     },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/optionator": {
       "version": "0.9.4",
@@ -9515,4 +9543,21 @@
       }
     },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/prop-types/node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
     "node_modules/proxy-from-env": {
       "version": "1.1.0",
@@ -9538,4 +9583,17 @@
       "engines": {
         "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-apexcharts": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-2.1.0.tgz",
+      "integrity": "sha512-xrmeTKRKHh3cvvLc8SasqFjlOgIqGpyHc81qjnRtcjUM0Fu7qEjgVRWGPokGFjqhwRZVgEym8zmuEyYr5LMYIg==",
+      "license": "SEE LICENSE IN LICENSE",
+      "dependencies": {
+        "prop-types": "^15.8.1"
+      },
+      "peerDependencies": {
+        "apexcharts": ">=5.10.1",
+        "react": ">=16.8.0"
       }
     },
Index: frontend/package.json
===================================================================
--- frontend/package.json	(revision e9a0543bbb622a2b343d1f5fe3b2df7ddb6a0bd3)
+++ frontend/package.json	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
@@ -17,7 +17,9 @@
     "@relume_io/relume-ui": "^1.3.1",
     "@tailwindcss/vite": "^4.1.18",
+    "apexcharts": "^5.10.6",
     "axios": "^1.13.4",
     "daisyui": "^5.5.18",
     "react": "^19.2.0",
+    "react-apexcharts": "^2.1.0",
     "react-dom": "^19.2.0",
     "react-icons": "^5.5.0",
Index: frontend/src/components/graphs/PercentChangeAreaChart.jsx
===================================================================
--- frontend/src/components/graphs/PercentChangeAreaChart.jsx	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
+++ frontend/src/components/graphs/PercentChangeAreaChart.jsx	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
@@ -0,0 +1,103 @@
+import React, { Suspense, lazy, useMemo } from "react";
+
+import { formatBucketLabel } from "../../utils/timeSeries.js";
+
+// Code-split the charting library (ApexCharts) so it doesn't bloat the initial bundle.
+const ApexChart = lazy(() => import("react-apexcharts"));
+
+export default function PercentChangeAreaChart({
+  points,
+  granularity,
+  height = 260,
+  positiveColor = "#22c55e",
+}) {
+  const series = useMemo(() => {
+    const data = (points ?? []).map((p) => ({ x: p.ts, y: Number(p.value ?? 0) }));
+    return [{ name: "% change", data }];
+  }, [points]);
+
+  const options = useMemo(() => {
+    return {
+      chart: {
+        type: "area",
+        toolbar: { show: false },
+        zoom: { enabled: false },
+        animations: { enabled: true, easing: "easeinout", speed: 450 },
+        fontFamily: "inherit",
+        foreColor: "rgba(255,255,255,0.72)",
+      },
+      stroke: {
+        curve: "smooth",
+        width: 2,
+      },
+      fill: {
+        type: "gradient",
+        gradient: {
+          shadeIntensity: 0.2,
+          opacityFrom: 0.35,
+          opacityTo: 0.05,
+          stops: [0, 90, 100],
+        },
+      },
+      dataLabels: { enabled: false },
+      grid: {
+        borderColor: "rgba(255,255,255,0.08)",
+        strokeDashArray: 4,
+      },
+      xaxis: {
+        type: "datetime",
+        labels: {
+          datetimeUTC: false,
+          formatter: (value, timestamp) => formatBucketLabel(timestamp, granularity),
+        },
+        axisBorder: { color: "rgba(255,255,255,0.10)" },
+        axisTicks: { color: "rgba(255,255,255,0.10)" },
+      },
+      yaxis: {
+        labels: {
+          formatter: (v) => `${Math.round(v)}%`,
+        },
+      },
+      tooltip: {
+        theme: "dark",
+        y: {
+          formatter: (_v, opts) => {
+            const p = points?.[opts.dataPointIndex];
+            const pct = Number(p?.value ?? 0);
+            const base = Number(p?.base ?? 0);
+            return `${pct.toFixed(1)}% (base ${Math.round(base)})`;
+          },
+        },
+      },
+      colors: [positiveColor],
+      annotations: {
+        yaxis: [
+          {
+            y: 0,
+            borderColor: "rgba(255,255,255,0.25)",
+            strokeDashArray: 4,
+          },
+        ],
+      },
+      // Color negative area differently by using a second series-like trick is overkill;
+      // we keep a single color and rely on the 0% line for readability.
+    };
+  }, [granularity, points, positiveColor]);
+
+  return (
+    <div className="w-full">
+      <Suspense
+        fallback={
+          <div className="flex items-center justify-center" style={{ height }}>
+            <span className="loading loading-spinner loading-md" />
+          </div>
+        }
+      >
+        <ApexChart options={options} series={series} type="area" height={height} />
+      </Suspense>
+    </div>
+  );
+}
+
+
+
Index: frontend/src/components/graphs/TimeRangeToggle.jsx
===================================================================
--- frontend/src/components/graphs/TimeRangeToggle.jsx	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
+++ frontend/src/components/graphs/TimeRangeToggle.jsx	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
@@ -0,0 +1,30 @@
+import React from "react";
+
+export default function TimeRangeToggle({ value, onChange }) {
+  const TIME_RANGES = [
+    { key: "daily", label: "Daily" },
+    { key: "weekly", label: "Weekly" },
+    { key: "monthly", label: "Monthly" },
+    { key: "yearly", label: "Yearly" },
+  ];
+
+  return (
+    <div className="join">
+      {TIME_RANGES.map((r) => (
+        <button
+          key={r.key}
+          type="button"
+          className={
+            "btn btn-xs join-item " +
+            (value === r.key ? "btn-success" : "btn-ghost")
+          }
+          onClick={() => onChange?.(r.key)}
+        >
+          {r.label}
+        </button>
+      ))}
+    </div>
+  );
+}
+
+
Index: frontend/src/pages/Dashboard/pages/Training/TrainingTracking.jsx
===================================================================
--- frontend/src/pages/Dashboard/pages/Training/TrainingTracking.jsx	(revision e9a0543bbb622a2b343d1f5fe3b2df7ddb6a0bd3)
+++ frontend/src/pages/Dashboard/pages/Training/TrainingTracking.jsx	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
@@ -2,5 +2,4 @@
 import { useNavigate } from "react-router-dom";
 import api from "../../../../api/axios";
-import graphPlaceholder from "../../../../assets/graph-placeholder.svg";
 
 import TrainingTrackingHeader from "./components/TrainingTrackingHeader.jsx";
@@ -103,5 +102,5 @@
         onAddSession={() => navigate("/dashboard/training/sessions/new")}
       />
-      <TrainingProgressCard graphSrc={graphPlaceholder} />
+      <TrainingProgressCard sessions={sessions} />
       <TrainingSessionsTable
         columns={columns}
Index: frontend/src/pages/Dashboard/pages/Training/components/TrainingProgressCard.jsx
===================================================================
--- frontend/src/pages/Dashboard/pages/Training/components/TrainingProgressCard.jsx	(revision e9a0543bbb622a2b343d1f5fe3b2df7ddb6a0bd3)
+++ frontend/src/pages/Dashboard/pages/Training/components/TrainingProgressCard.jsx	(revision b3d5fed65cdab945e6c84835e2e04bc1814d9699)
@@ -1,5 +1,25 @@
-import React from "react";
+import React, { useMemo, useState } from "react";
 
-const TrainingProgressCard = ({ graphSrc }) => {
+import TimeRangeToggle from "../../../../../components/graphs/TimeRangeToggle.jsx";
+import PercentChangeAreaChart from "../../../../../components/graphs/PercentChangeAreaChart.jsx";
+import { percentChangeSeries, sumByTimeBucket } from "../../../../../utils/timeSeries.js";
+
+const TrainingProgressCard = ({ sessions }) => {
+  const [range, setRange] = useState("weekly");
+
+  const points = useMemo(() => {
+    const buckets = sumByTimeBucket(
+      sessions,
+      range,
+      (s) => s.date,
+      (s) => s.calories
+    );
+    return percentChangeSeries(buckets);
+  }, [range, sessions]);
+
+  const latest = points?.length ? points[points.length - 1] : null;
+  const latestPct = latest ? Number(latest.value ?? 0) : 0;
+  const latestBase = latest ? Number(latest.base ?? 0) : 0;
+
   return (
     <div className="card bg-base-200 border border-base-300">
@@ -7,12 +27,29 @@
         <div className="flex items-center justify-between gap-4">
           <h2 className="card-title">Progress</h2>
-          <span className="badge badge-ghost">Graph placeholder</span>
+          <div className="flex items-center gap-3">
+            <span className="badge badge-ghost">
+              {latestPct >= 0 ? "+" : ""}
+              {latestPct.toFixed(1)}% (last)
+            </span>
+            <TimeRangeToggle value={range} onChange={setRange} />
+          </div>
         </div>
-        <div className="mt-4 w-full overflow-hidden rounded-xl border border-base-300 bg-base-100">
-          <img
-            src={graphSrc}
-            alt="Training progress graph placeholder"
-            className="block h-65 w-full object-cover"
-          />
+
+        <div className="mt-4 w-full overflow-hidden rounded-xl border border-base-300 bg-base-100 p-2">
+          {points.length < 2 ? (
+            <div className="flex h-65 items-center justify-center text-sm opacity-70">
+              Add at least 2 sessions to see % change.
+            </div>
+          ) : (
+            <PercentChangeAreaChart
+              points={points}
+              granularity={range}
+              height={260}
+            />
+          )}
+        </div>
+
+        <div className="mt-2 text-xs opacity-70">
+          Showing percentage change in calories burned per {range} bucket. Latest bucket total: {Math.round(latestBase)}.
         </div>
       </div>
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();
+  }
+}
+
