diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
index 27d7b494..6da0ae25 100644
--- a/.github/workflows/deploy-web.yml
+++ b/.github/workflows/deploy-web.yml
@@ -108,6 +108,8 @@ jobs:
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
+ # Browsers load the live stats through it, and the build reads it once to bake the
+ # numbers of this moment into the landing page's static HTML (lib/platformStats.ts).
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
@@ -121,6 +123,9 @@ jobs:
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
+ # Fail-soft like the star count: browsers still load the stats live; only crawlers and
+ # LLM fetchers, which run no JS, see no numbers until the next deploy.
+ grep -q 'data-stats-snapshot=' out/index.html || echo "::warning::Platform stats were not baked into the landing page (reason in the build log) - crawlers and LLM fetchers see no numbers until the next deploy"
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
- name: Package export
diff --git a/src/Web/autopilot-monitor-web/app/page.tsx b/src/Web/autopilot-monitor-web/app/page.tsx
index a05267c3..7bd85376 100644
--- a/src/Web/autopilot-monitor-web/app/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/page.tsx
@@ -1,41 +1,17 @@
-import { AuthGate } from "../components/landing/AuthGate";
-import { LandingNavbar } from "../components/landing/LandingNavbar";
-import { Hero } from "../components/landing/Hero";
-import { StatsBand } from "../components/landing/StatsBand";
-import { JustAsk } from "../components/landing/JustAsk";
-import { Story } from "../components/landing/Story";
-import { CapabilitiesStrip } from "../components/landing/CapabilitiesStrip";
-import { Comparison } from "../components/landing/Comparison";
-import { HowItWorks } from "../components/landing/HowItWorks";
-import { Testimonials } from "../components/landing/Testimonials";
-import { FinalCta } from "../components/landing/FinalCta";
-import { SiteFooter } from "../components/SiteFooter";
-import { MarketingTracker } from "../components/MarketingTracker";
+import { LandingPage } from "../components/landing/LandingPage";
+import { loadPlatformStatsSnapshot } from "../lib/platformStats";
+
+// The stats read below runs once per `next build` and bypasses Next's fetch cache (`no-store`,
+// see lib/platformStats.ts). force-static keeps that legal in the static export; the landing
+// tree reads no request-time API (cookies, headers, search params) that it would blank out.
+export const dynamic = "force-static";
/**
- * Landing page v2 — a scroll story of one enrollment.
- * All theming goes through the lp-* tokens (globals.css); sections live
- * in components/landing/. Auth behavior (AuthGate redirect, LoginButton
- * portal handoff) is unchanged from v1.
+ * The landing page with the platform stats of the build baked into its static HTML, so
+ * crawlers and LLM fetchers that run no JS see the numbers too; browsers then load the live
+ * ones (components/landing/StatsBand.tsx). Awaited here, not under a Suspense boundary: React
+ * would move a resolved boundary of a page this size into a hidden div at the end of the body.
*/
-export default function LandingPage() {
- return (
-
- {/* Client component: handles auth redirect + loading overlay */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
+export default async function Page() {
+ return ;
}
diff --git a/src/Web/autopilot-monitor-web/components/__tests__/landingStats.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/landingStats.test.ts
new file mode 100644
index 00000000..47bb5f9b
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/components/__tests__/landingStats.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect, vi } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { prerender } from "react-dom/static";
+
+// LandingNavbar and LoginButton read the auth context; the public pages render signed out.
+vi.mock("@/contexts/AuthContext", () => ({
+ useAuth: () => ({ login: async () => undefined, isAuthenticated: false, user: null, getAccessToken: async () => null }),
+}));
+// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
+vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
+
+const { SNAPSHOT } = vi.hoisted(() => ({
+ SNAPSHOT: {
+ items: [
+ { label: "enrollments monitored", value: "12,481" },
+ { label: "events processed", value: "8.3M" },
+ ],
+ asOf: "2026-10-02T14:00:03.1234567Z",
+ },
+}));
+// The blob read itself is covered in lib/__tests__/platformStats.test.ts.
+vi.mock("@/lib/platformStats", async importOriginal => ({
+ ...(await importOriginal()),
+ loadPlatformStatsSnapshot: async () => SNAPSHOT,
+}));
+
+import LandingRoute from "@/app/page";
+import { StatsBand } from "@/components/landing/StatsBand";
+
+/**
+ * Crawlers and LLM fetchers run no JS: the platform stats only reach them when the build renders
+ * the numbers into the landing page's HTML (lib/platformStats.ts, app/page.tsx).
+ */
+describe("platform stats in the landing page's static HTML", () => {
+ it("the band renders the baked numbers and marks the snapshot", () => {
+ const html = renderToStaticMarkup(createElement(StatsBand, { snapshot: SNAPSHOT }));
+ expect(html).toContain(">12,481<");
+ expect(html).toContain(">enrollments monitored<");
+ expect(html).toContain(">8.3M<");
+ expect(html).toContain(`data-stats-snapshot="${SNAPSHOT.asOf}"`);
+ expect(html).not.toContain("animate-pulse");
+ });
+
+ it("without a snapshot the band shows the loading skeleton and no marker", () => {
+ const html = renderToStaticMarkup(createElement(StatsBand, { snapshot: null }));
+ expect(html).toContain('data-track-section="stats"');
+ expect(html).toContain("animate-pulse");
+ expect(html).not.toContain("data-stats-snapshot");
+ });
+
+ // A Suspense boundary around the read would still resolve at build time, but React moves a
+ // resolved boundary of a page this size into a hidden div at the end of the body.
+ it("the page renders the numbers in place, not in a hidden streaming segment", async () => {
+ const { prelude } = await prerender(createElement(LandingRoute));
+ const html = await new Response(prelude).text();
+
+ const bandStart = html.indexOf('data-track-section="stats"');
+ const nextSection = html.indexOf('data-track-section="just_ask"');
+ const figure = html.indexOf(">12,481<");
+ expect(bandStart).toBeGreaterThan(-1);
+ expect(figure).toBeGreaterThan(bandStart);
+ expect(figure).toBeLessThan(nextSection);
+ expect(html).toContain(`data-stats-snapshot="${SNAPSHOT.asOf}"`);
+ expect(html).not.toMatch(/ ({
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
-import LandingPage from "@/app/page";
+import { LandingPage } from "@/components/landing/LandingPage";
import GetStartedPage from "@/app/get-started/page";
import AiPage from "@/app/ai/page";
import AboutPage from "@/app/about/page";
@@ -25,7 +25,8 @@ import TermsPage from "@/app/terms/page";
*/
const PAGES: Record
= {
- landing: LandingPage,
+ // app/page.tsx only awaits the build-time stats and renders this body; the sync renderer cannot await.
+ landing: () => createElement(LandingPage, { statsSnapshot: null }),
"get-started": GetStartedPage,
ai: AiPage,
about: AboutPage,
diff --git a/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts
index acaf80bd..ef1a19be 100644
--- a/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts
+++ b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts
@@ -11,7 +11,7 @@ vi.mock("@/contexts/AuthContext", () => ({
// AuthGate only redirects signed-in visitors (it needs the app router) and renders no links.
vi.mock("@/components/landing/AuthGate", () => ({ AuthGate: () => null }));
-import LandingPage from "@/app/page";
+import { LandingPage } from "@/components/landing/LandingPage";
import GetStartedPage from "@/app/get-started/page";
import AiPage from "@/app/ai/page";
import robots from "@/app/robots";
@@ -27,6 +27,8 @@ import { sitePageUrl } from "@/utils/config";
*/
const render = (Page: ComponentType) => renderToStaticMarkup(createElement(Page));
+// app/page.tsx only awaits the build-time stats and renders this body; the sync renderer cannot await.
+const Landing = () => createElement(LandingPage, { statsSnapshot: null });
const trackIds = (html: string) => [...html.matchAll(/data-track="([^"]+)"/g)].map(m => m[1]);
const sectionIds = (html: string) => [...html.matchAll(/data-track-section="([^"]+)"/g)].map(m => m[1]);
const untracked = (html: string) =>
@@ -37,7 +39,7 @@ const untracked = (html: string) =>
const PAGES: { name: string; Page: ComponentType; sections: string[] }[] = [
{
name: "landing",
- Page: LandingPage,
+ Page: Landing,
sections: ["hero", "stats", "just_ask", "story", "capabilities", "comparison", "how_it_works", "testimonials", "final_cta"],
},
{ name: "get-started", Page: GetStartedPage, sections: ["steps", "signup"] },
@@ -60,7 +62,7 @@ describe("marketing page tracking", () => {
}
it("the landing links to the AI page from the navbar, Then just ask and the footer", () => {
- const ids = trackIds(render(LandingPage));
+ const ids = trackIds(render(Landing));
expect(ids).toEqual(expect.arrayContaining(["nav_ai", "just_ask_ai", "footer_ai"]));
});
diff --git a/src/Web/autopilot-monitor-web/components/landing/LandingPage.tsx b/src/Web/autopilot-monitor-web/components/landing/LandingPage.tsx
new file mode 100644
index 00000000..c5ff50c9
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/components/landing/LandingPage.tsx
@@ -0,0 +1,46 @@
+import { AuthGate } from "./AuthGate";
+import { LandingNavbar } from "./LandingNavbar";
+import { Hero } from "./Hero";
+import { StatsBand } from "./StatsBand";
+import { JustAsk } from "./JustAsk";
+import { Story } from "./Story";
+import { CapabilitiesStrip } from "./CapabilitiesStrip";
+import { Comparison } from "./Comparison";
+import { HowItWorks } from "./HowItWorks";
+import { Testimonials } from "./Testimonials";
+import { FinalCta } from "./FinalCta";
+import { SiteFooter } from "../SiteFooter";
+import { MarketingTracker } from "../MarketingTracker";
+import type { PlatformStatsSnapshot } from "@/lib/platformStats";
+
+/**
+ * Landing page v2 — a scroll story of one enrollment.
+ * All theming goes through the lp-* tokens (globals.css); sections live
+ * in components/landing/. Auth behavior (AuthGate redirect, LoginButton
+ * portal handoff) is unchanged from v1.
+ *
+ * Synchronous on purpose: app/page.tsx awaits the build-time stats and hands
+ * them in, and the marketing guard tests render this body with the sync
+ * renderToStaticMarkup.
+ */
+export function LandingPage({ statsSnapshot }: { statsSnapshot: PlatformStatsSnapshot | null }) {
+ return (
+
+ {/* Client component: handles auth redirect + loading overlay */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/components/landing/StatsBand.tsx b/src/Web/autopilot-monitor-web/components/landing/StatsBand.tsx
index a8af62fd..9908c125 100644
--- a/src/Web/autopilot-monitor-web/components/landing/StatsBand.tsx
+++ b/src/Web/autopilot-monitor-web/components/landing/StatsBand.tsx
@@ -1,61 +1,12 @@
"use client";
import { useEffect, useState } from "react";
-
-interface PlatformStatsManifest {
- latest: string;
- generatedAtUtc: string;
-}
-
-interface PlatformStatsPayload {
- totalEnrollments?: number;
- totalUsers?: number;
- totalTenants?: number;
- totalSignedUpTenants?: number;
- uniqueDeviceModels?: number;
- totalEventsProcessed?: number;
- successfulEnrollments?: number;
- issuesDetected?: number;
- lastFullCompute?: string;
- lastUpdated?: string;
-}
-
-function resolvePlatformStatsManifestUrl(rawUrl?: string): string {
- const trimmed = rawUrl?.trim();
- if (!trimmed) {
- return "/platform-stats.json";
- }
-
- const hashIndex = trimmed.indexOf("#");
- const withoutHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
- const hash = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
-
- const queryIndex = withoutHash.indexOf("?");
- const basePath = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash;
- const query = queryIndex >= 0 ? withoutHash.slice(queryIndex) : "";
-
- if (/\.json$/i.test(basePath)) {
- return trimmed;
- }
-
- const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
- const manifestPath = `${normalizedBasePath}/platform-stats.json`;
- return `${manifestPath}${query}${hash}`;
-}
-
-const PLATFORM_STATS_MANIFEST_URL =
- resolvePlatformStatsManifestUrl(process.env.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL);
-
-interface StatItem {
- label: string;
- value: string;
-}
-
-/** 8,341,206 → "8.3M"; smaller values keep their grouped form. */
-function compact(n: number): string {
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
- return n.toLocaleString("en-US");
-}
+import {
+ fetchLivePlatformStats,
+ toStatItems,
+ type PlatformStatsSnapshot,
+ type StatItem,
+} from "@/lib/platformStats";
/**
* Sample values for local development only, where no stats blob is
@@ -71,81 +22,51 @@ const DEV_SAMPLE_STATS: StatItem[] = [
];
/**
- * Full-bleed platform stats band under the hero shot. Skeleton while
- * loading; on fetch failure it shows dev sample data locally and
- * disappears entirely in production (never an endless skeleton, never
- * fake numbers).
+ * Full-bleed platform stats band under the hero shot. Starts with the
+ * numbers the build baked in (`snapshot`, lib/platformStats.ts), so the
+ * static HTML carries them for crawlers and LLM fetchers, then swaps in the
+ * live numbers. A failed live read keeps the baked numbers; without either,
+ * it shows dev sample data locally and disappears entirely in production
+ * (never an endless skeleton, never fake numbers). The skeleton only shows
+ * while a page built without a snapshot loads.
*/
-export function StatsBand() {
- const [stats, setStats] = useState(null);
+export function StatsBand({ snapshot }: { snapshot: PlatformStatsSnapshot | null }) {
+ const [live, setLive] = useState(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
- const fail = () => {
+ const loadLiveStats = async () => {
+ const payload = await fetchLivePlatformStats();
if (cancelled) return;
- if (process.env.NODE_ENV === "development") {
- setStats(DEV_SAMPLE_STATS);
+ const items = payload ? toStatItems(payload) : [];
+ if (items.length > 0) {
+ setLive(items);
} else {
setFailed(true);
}
};
- const loadPlatformStats = async () => {
- try {
- const manifestResponse = await fetch(PLATFORM_STATS_MANIFEST_URL, { cache: "no-store" });
- if (!manifestResponse.ok) return fail();
-
- const manifest = (await manifestResponse.json()) as PlatformStatsManifest;
- if (!manifest?.latest) return fail();
-
- const versionedUrl = new URL(manifest.latest, manifestResponse.url).toString();
- const statsResponse = await fetch(versionedUrl, { cache: "force-cache" });
- if (!statsResponse.ok) return fail();
-
- const payload = (await statsResponse.json()) as PlatformStatsPayload;
- if (cancelled) return;
-
- const items: StatItem[] = [];
- if (payload.totalEnrollments) {
- items.push({ label: "enrollments monitored", value: payload.totalEnrollments.toLocaleString("en-US") });
- }
- if (payload.issuesDetected) {
- items.push({ label: "issues detected", value: payload.issuesDetected.toLocaleString("en-US") });
- }
- if (payload.totalSignedUpTenants) {
- items.push({ label: "organisations", value: payload.totalSignedUpTenants.toLocaleString("en-US") });
- }
- if (payload.uniqueDeviceModels) {
- items.push({ label: "device models", value: payload.uniqueDeviceModels.toLocaleString("en-US") });
- }
- if (payload.totalEventsProcessed) {
- items.push({ label: "events processed", value: compact(payload.totalEventsProcessed) });
- }
- if (items.length > 0) {
- setStats(items);
- } else {
- fail();
- }
- } catch {
- // The band is decorative and must never break the page.
- fail();
- }
- };
-
- loadPlatformStats();
+ loadLiveStats();
return () => {
cancelled = true;
};
}, []);
- if (failed) {
+ const devFallback = failed && process.env.NODE_ENV === "development" ? DEV_SAMPLE_STATS : null;
+ const stats = live ?? snapshot?.items ?? devFallback;
+ if (!stats && failed) {
return null;
}
return (
-
+
{(stats ?? Array.from({ length: 5 }, () => null)).map((item, i) =>
item ? (
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts
index cb567959..d716db32 100644
--- a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts
@@ -6,7 +6,6 @@
/** Files that keep a raw site for a stated reason; they never leave the baseline. */
export const PERMANENT: Record
= {
"contexts/AuthContext.tsx": "auth/me bootstrap runs before the auth context exists: raw fetch by design",
- "components/landing/StatsBand.tsx": "unauthenticated public blob manifest, not the API",
"app/health-check/page.tsx": "/version.json of the SWA itself, not the API",
"app/dashboard/hooks/deleteSessionResponse.ts": "safeJson on the 202 Response of the delete-cascade classifier (refusals arrive as ApiError)",
"app/settings/TenantConfigContext.tsx": "consent return path routes on TokenExpiredError (router.replace); the access check rethrows it as a non-outcome",
@@ -33,7 +32,6 @@ export const DEDUPEDAUTHFETCH_BASELINE: Record = {
export const JSONPARSE_BASELINE: Record = {
"app/dashboard/hooks/deleteSessionResponse.ts": 1,
"app/health-check/page.tsx": 1,
- "components/landing/StatsBand.tsx": 2,
"contexts/AuthContext.tsx": 2,
};
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/platformStats.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/platformStats.test.ts
new file mode 100644
index 00000000..54f98598
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/platformStats.test.ts
@@ -0,0 +1,188 @@
+import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
+import {
+ fetchLivePlatformStats,
+ loadPlatformStatsSnapshot,
+ resolvePlatformStatsManifestUrl,
+ toStatItems,
+} from "../platformStats";
+
+const CONTAINER = "https://stats.example.test/publicstats";
+const MANIFEST_URL = `${CONTAINER}/platform-stats.json`;
+const PAYLOAD_URL = `${CONTAINER}/platform-stats.2026-10-02.json`;
+
+/** What the backend publishes (MaintenanceService.TryPublishPlatformStatsJsonAsync). */
+const MANIFEST = { latest: "platform-stats.2026-10-02.json", generatedAtUtc: "2026-10-02T14:00:05.1234567Z" };
+const PAYLOAD = {
+ totalEnrollments: 12481,
+ totalUsers: 310,
+ totalTenants: 92,
+ totalSignedUpTenants: 87,
+ uniqueDeviceModels: 214,
+ totalEventsProcessed: 8341206,
+ successfulEnrollments: 11020,
+ issuesDetected: 1847,
+ lastFullCompute: "2026-10-02T14:00:03.1234567Z",
+ lastUpdated: "2026-10-02T14:00:03.1234567Z",
+};
+
+const BAND = [
+ { label: "enrollments monitored", value: "12,481" },
+ { label: "issues detected", value: "1,847" },
+ { label: "organisations", value: "87" },
+ { label: "device models", value: "214" },
+ { label: "events processed", value: "8.3M" },
+];
+
+function json(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
+}
+
+/** Answers by URL; anything not listed is a 404. */
+function serve(routes: Record Response>) {
+ const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(async input =>
+ routes[input]?.() ?? new Response("not found", { status: 404 }));
+ vi.stubGlobal("fetch", fetchMock);
+ return fetchMock;
+}
+
+const BOTH_FILES = { [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json(PAYLOAD) };
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe("resolvePlatformStatsManifestUrl", () => {
+ it("falls back to the same-origin manifest without a configured URL", () => {
+ expect([undefined, "", " "].map(resolvePlatformStatsManifestUrl)).toEqual(Array(3).fill("/platform-stats.json"));
+ });
+
+ it("appends the manifest name to a container URL, with or without a trailing slash", () => {
+ expect(resolvePlatformStatsManifestUrl(CONTAINER)).toBe(MANIFEST_URL);
+ expect(resolvePlatformStatsManifestUrl(`${CONTAINER}/`)).toBe(MANIFEST_URL);
+ });
+
+ it("keeps a URL that already names a JSON file", () => {
+ expect(resolvePlatformStatsManifestUrl(MANIFEST_URL)).toBe(MANIFEST_URL);
+ expect(resolvePlatformStatsManifestUrl(`${CONTAINER}/custom.JSON`)).toBe(`${CONTAINER}/custom.JSON`);
+ });
+
+ it("keeps query and hash behind the appended manifest name", () => {
+ expect(resolvePlatformStatsManifestUrl(`${CONTAINER}?v=1#x`)).toBe(`${MANIFEST_URL}?v=1#x`);
+ });
+});
+
+describe("toStatItems", () => {
+ it("maps the band's figures in display order", () => {
+ expect(toStatItems(PAYLOAD)).toEqual(BAND);
+ });
+
+ it("leaves out figures that are missing, zero, negative or not a finite number", () => {
+ const payload = {
+ totalEnrollments: 0,
+ issuesDetected: -5,
+ totalSignedUpTenants: Number.POSITIVE_INFINITY,
+ uniqueDeviceModels: "214" as unknown as number,
+ totalEventsProcessed: 999_999,
+ };
+ expect(toStatItems(payload)).toEqual([{ label: "events processed", value: "999,999" }]);
+ expect(toStatItems({})).toEqual([]);
+ });
+});
+
+describe("fetchLivePlatformStats", () => {
+ it("reads the manifest, then the file it names, revalidating both with the blob", async () => {
+ const fetchMock = serve(BOTH_FILES);
+
+ expect(await fetchLivePlatformStats(MANIFEST_URL)).toEqual(PAYLOAD);
+ expect(fetchMock.mock.calls).toEqual([
+ [MANIFEST_URL, { cache: "no-cache" }],
+ [PAYLOAD_URL, { cache: "no-cache" }],
+ ]);
+ });
+
+ it("resolves the versioned file against the manifest as served, after redirects", async () => {
+ const redirected = json(MANIFEST);
+ Object.defineProperty(redirected, "url", { value: "https://stats.example.test/v2/platform-stats.json" });
+ const fetchMock = serve({
+ [MANIFEST_URL]: () => redirected,
+ "https://stats.example.test/v2/platform-stats.2026-10-02.json": () => json(PAYLOAD),
+ });
+
+ expect(await fetchLivePlatformStats(MANIFEST_URL)).toEqual(PAYLOAD);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ const failures: [string, Record Response>][] = [
+ ["the manifest is missing", { [PAYLOAD_URL]: () => json(PAYLOAD) }],
+ ["the manifest names no file", { [MANIFEST_URL]: () => json({ generatedAtUtc: MANIFEST.generatedAtUtc }) }],
+ ["the manifest is not JSON", { [MANIFEST_URL]: () => new Response("") }],
+ ["the manifest names a file on another origin", { [MANIFEST_URL]: () => json({ latest: "https://elsewhere.example.test/stats.json" }) }],
+ ["the versioned file is missing", { [MANIFEST_URL]: () => json(MANIFEST) }],
+ ["the versioned file holds no object", { [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json(42) }],
+ ];
+ for (const [name, routes] of failures) {
+ it(`returns null when ${name}`, async () => {
+ serve(routes);
+ expect(await fetchLivePlatformStats(MANIFEST_URL)).toBeNull();
+ });
+ }
+
+ it("returns null when the network fails", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
+ expect(await fetchLivePlatformStats(MANIFEST_URL)).toBeNull();
+ });
+});
+
+describe("loadPlatformStatsSnapshot", () => {
+ let log: MockInstance;
+ let warn: MockInstance;
+ beforeEach(() => {
+ log = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ });
+
+ it("bakes nothing and sends no request without an absolute URL (local and CI builds)", async () => {
+ const fetchMock = serve(BOTH_FILES);
+
+ expect(await loadPlatformStatsSnapshot("/platform-stats.json")).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it("returns the band's items and the stats timestamp, read past Next's fetch cache within a deadline", async () => {
+ const fetchMock = serve(BOTH_FILES);
+
+ expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toEqual({ items: BAND, asOf: PAYLOAD.lastUpdated });
+ const inits = fetchMock.mock.calls.map(([, init]) => init);
+ expect(inits).toHaveLength(2);
+ for (const init of inits) {
+ expect(init?.cache).toBe("no-store");
+ expect(init?.signal).toBeInstanceOf(AbortSignal);
+ }
+ expect(inits[0]?.signal).toBe(inits[1]?.signal);
+ expect(log).toHaveBeenCalledWith(expect.stringContaining(PAYLOAD.lastUpdated));
+ });
+
+ it("fails soft with the reason in the build log when the blob does not answer", async () => {
+ serve({ [MANIFEST_URL]: () => new Response("unavailable", { status: 503 }) });
+
+ expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("HTTP 503"));
+ });
+
+ it("names the network cause behind Node's generic fetch failure", async () => {
+ const failure = new TypeError("fetch failed", { cause: new Error("getaddrinfo ENOTFOUND stats.example.test") });
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(failure));
+
+ expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("fetch failed (getaddrinfo ENOTFOUND stats.example.test)"));
+ });
+
+ it("bakes nothing when the file holds no positive figure", async () => {
+ serve({ [MANIFEST_URL]: () => json(MANIFEST), [PAYLOAD_URL]: () => json({ ...PAYLOAD, totalEnrollments: 0, issuesDetected: 0, totalSignedUpTenants: 0, uniqueDeviceModels: 0, totalEventsProcessed: 0 }) });
+
+ expect(await loadPlatformStatsSnapshot(MANIFEST_URL)).toBeNull();
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("no positive figure"));
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/lib/platformStats.ts b/src/Web/autopilot-monitor-web/lib/platformStats.ts
new file mode 100644
index 00000000..23b698c3
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/platformStats.ts
@@ -0,0 +1,166 @@
+import type { PlatformStats } from "@/utils/wire-types.generated";
+
+/**
+ * The landing page's platform stats. A public blob container holds a manifest
+ * (`platform-stats.json`, rewritten by the backend maintenance run every 2 hours) that names the
+ * versioned file with the numbers (`latest`, relative to the manifest).
+ *
+ * Read twice: once by `next build`, which bakes the numbers of that moment into the static HTML
+ * (crawlers and LLM fetchers run no JS and would otherwise see only a skeleton), and again by
+ * every browser, which swaps in the live numbers (components/landing/StatsBand.tsx). Both reads
+ * share the URL, fetch and formatting rules here.
+ */
+
+interface PlatformStatsManifest {
+ latest: string;
+ generatedAtUtc: string;
+}
+
+/**
+ * The versioned file. The backend writes it field by field under the names of the wire type
+ * PlatformStats; every field stays optional because the body is parsed, not trusted.
+ */
+export type PlatformStatsPayload = Partial;
+
+export interface StatItem {
+ label: string;
+ value: string;
+}
+
+/** The numbers baked into the static HTML at build time. */
+export interface PlatformStatsSnapshot {
+ items: StatItem[];
+ /** When the backend computed them (the payload's lastUpdated): page marker and build log. */
+ asOf: string | null;
+}
+
+export function resolvePlatformStatsManifestUrl(rawUrl?: string): string {
+ const trimmed = rawUrl?.trim();
+ if (!trimmed) {
+ return "/platform-stats.json";
+ }
+
+ const hashIndex = trimmed.indexOf("#");
+ const withoutHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
+ const hash = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
+
+ const queryIndex = withoutHash.indexOf("?");
+ const basePath = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash;
+ const query = queryIndex >= 0 ? withoutHash.slice(queryIndex) : "";
+
+ if (/\.json$/i.test(basePath)) {
+ return trimmed;
+ }
+
+ const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
+ const manifestPath = `${normalizedBasePath}/platform-stats.json`;
+ return `${manifestPath}${query}${hash}`;
+}
+
+export const PLATFORM_STATS_MANIFEST_URL =
+ resolvePlatformStatsManifestUrl(process.env.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL);
+
+/** Budget for both build-time requests together: a slow blob must not stall the deploy. */
+const BUILD_FETCH_TIMEOUT_MS = 10_000;
+
+/** 8,341,206 → "8.3M"; smaller values keep their grouped form. */
+function compact(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ return n.toLocaleString("en-US");
+}
+
+function grouped(n: number): string {
+ return n.toLocaleString("en-US");
+}
+
+/** The band's figures in display order. */
+const STAT_FIELDS: { field: keyof PlatformStats; label: string; format: (n: number) => string }[] = [
+ { field: "totalEnrollments", label: "enrollments monitored", format: grouped },
+ { field: "issuesDetected", label: "issues detected", format: grouped },
+ { field: "totalSignedUpTenants", label: "organisations", format: grouped },
+ { field: "uniqueDeviceModels", label: "device models", format: grouped },
+ { field: "totalEventsProcessed", label: "events processed", format: compact },
+];
+
+/**
+ * The band's items. Only finite positive numbers count: the values end up in the static HTML,
+ * and a zero means "not computed yet", not a figure to publish.
+ */
+export function toStatItems(payload: PlatformStatsPayload): StatItem[] {
+ return STAT_FIELDS.flatMap(({ field, label, format }) => {
+ const value = payload[field];
+ return typeof value === "number" && Number.isFinite(value) && value > 0
+ ? [{ label, value: format(value) }]
+ : [];
+ });
+}
+
+/** Manifest, then the file it names. Throws with the reason; the callers decide what to report. */
+async function readPlatformStats(manifestUrl: string, init: RequestInit): Promise {
+ const manifestResponse = await fetch(manifestUrl, init);
+ if (!manifestResponse.ok) throw new Error(`${manifestUrl} answered HTTP ${manifestResponse.status}`);
+ const manifest = (await manifestResponse.json()) as Partial | null;
+ if (typeof manifest?.latest !== "string" || !manifest.latest) {
+ throw new Error(`${manifestUrl} names no versioned file`);
+ }
+
+ // Relative to the manifest as served (after redirects); a hand-built Response has no url.
+ const base = manifestResponse.url || manifestUrl;
+ const payloadUrl = new URL(manifest.latest, base);
+ if (payloadUrl.origin !== new URL(base).origin) {
+ throw new Error(`${manifestUrl} names a file on another origin`);
+ }
+
+ const payloadResponse = await fetch(payloadUrl.toString(), init);
+ if (!payloadResponse.ok) throw new Error(`${payloadUrl} answered HTTP ${payloadResponse.status}`);
+ const payload: unknown = await payloadResponse.json();
+ if (typeof payload !== "object" || payload === null) throw new Error(`${payloadUrl} holds no stats object`);
+ return payload as PlatformStatsPayload;
+}
+
+/**
+ * The browser's read. `no-cache` revalidates both files with the blob: the versioned file is
+ * named per day but rewritten every 2 hours under `Cache-Control: immutable`, so a copy cached
+ * earlier that day would replace the newer baked numbers with older ones.
+ */
+export async function fetchLivePlatformStats(
+ manifestUrl: string = PLATFORM_STATS_MANIFEST_URL,
+): Promise {
+ try {
+ return await readPlatformStats(manifestUrl, { cache: "no-cache" });
+ } catch {
+ // The band is decorative: a failed read only means no live numbers.
+ return null;
+ }
+}
+
+/**
+ * The build's read (app/page.tsx), once per `next build`. Only for an absolute URL: local and CI
+ * builds have none (the relative fallback exists only in a browser) and bake nothing.
+ * `no-store` keeps the read out of Next's fetch cache, which would otherwise hand the next build
+ * on the same machine the previous numbers; the page is `force-static`, so `no-store` stays
+ * legal in the static export. Fail-soft: without a snapshot the page ships the skeleton and
+ * browsers load the numbers live, as before; deploy-web.yml reports it as a warning.
+ */
+export async function loadPlatformStatsSnapshot(
+ manifestUrl: string = PLATFORM_STATS_MANIFEST_URL,
+): Promise {
+ if (!/^https?:\/\//i.test(manifestUrl)) return null;
+ try {
+ const payload = await readPlatformStats(manifestUrl, {
+ cache: "no-store",
+ signal: AbortSignal.timeout(BUILD_FETCH_TIMEOUT_MS),
+ });
+ const items = toStatItems(payload);
+ if (items.length === 0) throw new Error("the stats file holds no positive figure");
+ const asOf = typeof payload.lastUpdated === "string" ? payload.lastUpdated : null;
+ console.log(`Platform stats baked into the landing page (as of ${asOf ?? "unknown"}).`);
+ return { items, asOf };
+ } catch (err) {
+ // Node's fetch reports every network failure as "fetch failed" and keeps the reason in `cause`.
+ const cause = err instanceof Error && err.cause instanceof Error ? ` (${err.cause.message})` : "";
+ const reason = err instanceof Error ? `${err.message}${cause}` : String(err);
+ console.warn(`Platform stats not baked into the landing page; browsers still load them live. ${reason}`);
+ return null;
+ }
+}
diff --git a/src/Web/autopilot-monitor-web/scripts/generate-lastmod.js b/src/Web/autopilot-monitor-web/scripts/generate-lastmod.js
index f08e47a3..f29f567f 100644
--- a/src/Web/autopilot-monitor-web/scripts/generate-lastmod.js
+++ b/src/Web/autopilot-monitor-web/scripts/generate-lastmod.js
@@ -18,7 +18,7 @@ const OUTPUT_FILE = path.join(WEB_ROOT, "utils/page-lastmod.generated.ts");
// fails the build for any URL missing here. Documentation and the changelog
// live on docs.autopilotmonitor.com (GitBook) and are not part of this site.
const PAGE_MAP = {
- "/": ["app/page.tsx"],
+ "/": ["app/page.tsx", "components/landing/LandingPage.tsx"],
"/about": ["app/about/page.tsx", "app/about/faq.ts"],
"/ai": ["app/ai/page.tsx", "components/ai/questions.ts"],
"/get-started": ["app/get-started/page.tsx"],