From a7c3c16054ba3379d337683efb47fc93946ab08d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 20 Oct 2026 01:15:29 +0200 Subject: [PATCH] Global Admin sees new customers per day and the growth over time under Metrics > Customers (D-365) The section reads the tenant configurations from config/all and derives everything in the browser: no endpoint, no counter, no backfill. - Customers: new tenants per UTC day (per Monday-anchored week beyond 90 days), the window's best day, weekly average, the last 7 days, the total and the cumulative gain; 7/30/90/365 days in ?days=; clicking a column lists that bucket's tenants with their edition. A signup is a configuration row with a domain. The section is the first Metrics entry and the target of /admin/metrics. - Charts: AppBarChart takes a category formatter, a custom tooltip, a column click and whole-number ticks; AppLineChart can hide its legend and use whole-number ticks. Existing callers get the same props as before. - The Monday-of-week helper moves from the Fleet Health timeline into lib/timeWindow; the Stat tile duplicated in two metrics sections becomes one component, and the duplicate baseline loses that entry. Co-Authored-By: Claude Opus 5.5 --- .../admin/metrics/[section]/SectionClient.tsx | 2 + .../app/admin/metrics/metricsNavSections.ts | 1 + .../app/admin/metrics/page.tsx | 2 +- .../metrics/sections/SectionCustomers.tsx | 275 ++++++++++++++++++ .../sections/SectionImePatternHealth.tsx | 11 +- .../sections/SectionVerdictCalibration.tsx | 11 +- .../app/admin/metrics/sections/Stat.tsx | 10 + .../__tests__/customerGrowthLogic.test.ts | 168 +++++++++++ .../metrics/sections/customerGrowthLogic.ts | 121 ++++++++ .../app/fleet-health/timelineBars.ts | 10 +- .../components/charts/AppBarChart.tsx | 46 ++- .../components/charts/AppLineChart.tsx | 9 +- .../lib/__tests__/timeWindow.test.ts | 8 + .../lib/globalNavConfig.tsx | 1 + .../autopilot-monitor-web/lib/timeWindow.ts | 7 + tools/quality/jscpd-baseline.json | 1 - 16 files changed, 651 insertions(+), 32 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionCustomers.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/metrics/sections/Stat.tsx create mode 100644 src/Web/autopilot-monitor-web/app/admin/metrics/sections/__tests__/customerGrowthLogic.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/admin/metrics/sections/customerGrowthLogic.ts diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/[section]/SectionClient.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/[section]/SectionClient.tsx index cec06eb7..dc99d22f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/[section]/SectionClient.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/[section]/SectionClient.tsx @@ -3,12 +3,14 @@ import { notFound } from "next/navigation"; import { type MetricsSectionId } from "../metricsNavSections"; import { SectionAgentMetrics } from "../sections/SectionAgentMetrics"; +import { SectionCustomers } from "../sections/SectionCustomers"; import { SectionPlatformUsage } from "../sections/SectionPlatformUsage"; import { SectionMcpUsage } from "../sections/SectionMcpUsage"; import { SectionVerdictCalibration } from "../sections/SectionVerdictCalibration"; import { SectionImePatternHealth } from "../sections/SectionImePatternHealth"; const SECTION_COMPONENTS: Record = { + "customers": SectionCustomers, "platform-metrics": SectionAgentMetrics, "usage": SectionPlatformUsage, "mcp-usage": SectionMcpUsage, diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/metricsNavSections.ts b/src/Web/autopilot-monitor-web/app/admin/metrics/metricsNavSections.ts index 8758dc81..55116e21 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/metricsNavSections.ts +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/metricsNavSections.ts @@ -1,4 +1,5 @@ export const METRICS_NAV_SECTIONS = [ + { id: "customers", label: "Customers", description: "New tenants per day and the growth over time" }, { id: "platform-metrics", label: "Platform Metrics", description: "Agent performance, delivery latency, crash rates, and platform health" }, { id: "usage", label: "Platform Usage", description: "Platform usage statistics across all tenants" }, { id: "mcp-usage", label: "MCP Usage", description: "MCP API usage metrics across all users" }, diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/page.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/page.tsx index 14597038..3d14563f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/page.tsx @@ -5,5 +5,5 @@ import { ClientRedirect } from "@/components/ClientRedirect"; // Index route → default section. Client-side replace: server redirect() is not // supported under output:'export'. export default function IndexRedirect() { - return ; + return ; } diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionCustomers.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionCustomers.tsx new file mode 100644 index 00000000..d37c8225 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionCustomers.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { useAuth } from "../../../../contexts/AuthContext"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import { api } from "@/lib/api"; +import { SegmentedControl } from "@/components/SegmentedControl"; +import { CardSkeleton } from "@/components/skeletons/PageSkeleton"; +import { chartColors } from "@/components/charts/chartTheme"; +import { useWindowDays } from "@/hooks/useWindowDays"; +import { PLATFORM_MAX_WINDOW_DAYS, WINDOW_PRESET_OPTIONS } from "@/lib/timeWindow"; +import type { TenantConfiguration } from "@/lib/generated/wire-types.generated"; +import { editionLabel, effectiveEdition, facetOption } from "../../components/tenantFilters"; +import { customerGrowth, type GrowthBucket } from "./customerGrowthLogic"; +import { Stat } from "./Stat"; + +function ChartPlaceholder() { + return
Loading chart…
; +} + +// recharts (~300 kB) loads only here: SectionClient imports every metrics section statically. +const AppBarChart = dynamic(() => import("@/components/charts/AppBarChart"), { ssr: false, loading: ChartPlaceholder }); +const AppLineChart = dynamic(() => import("@/components/charts/AppLineChart"), { ssr: false, loading: ChartPlaceholder }); + +/** The portal presets plus the year. */ +const RANGE_OPTIONS = [...WINDOW_PRESET_OPTIONS, { value: PLATFORM_MAX_WINDOW_DAYS, label: `${PLATFORM_MAX_WINDOW_DAYS} Days` }]; +const DEFAULT_WINDOW_DAYS = 30; + +const WEEKDAY: Intl.DateTimeFormatOptions = { weekday: "short" }; +const SHORT_DAY: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" }; +const FULL_DAY: Intl.DateTimeFormatOptions = { weekday: "short", day: "numeric", month: "short", year: "numeric" }; +const RANGE_DAY: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" }; + +/** A UTC calendar day (yyyy-MM-dd) in the viewer's locale, never shifted by the viewer's time zone. */ +function formatDay(key: string, options: Intl.DateTimeFormatOptions): string { + return new Date(`${key}T00:00:00Z`).toLocaleDateString(undefined, { ...options, timeZone: "UTC" }); +} + +function bucketTitle(bucket: { startKey: string; endKey: string }, weekly: boolean): string { + return weekly + ? `${formatDay(bucket.startKey, RANGE_DAY)} – ${formatDay(bucket.endKey, RANGE_DAY)}` + : formatDay(bucket.startKey, FULL_DAY); +} + +function signupTime(atMs: number, weekly: boolean): string { + const options: Intl.DateTimeFormatOptions = weekly + ? { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", timeZone: "UTC" } + : { hour: "2-digit", minute: "2-digit", timeZone: "UTC" }; + return `${new Date(atMs).toLocaleString(undefined, options)} UTC`; +} + +export function SectionCustomers() { + // useSearchParams (the ?days= window) requires a Suspense boundary under the static export. + return ( + }> + + + ); +} + +function CustomersContent() { + const { getAccessToken } = useAuth(); + // Platform roles may request up to 365 days; the section has no tenant of its own. + const { days, setDays } = useWindowDays({ defaultDays: DEFAULT_WINDOW_DAYS, tenantId: undefined }); + // The rows and the clock they were read at, set together so a render never mixes two fetches. + const [snapshot, setSnapshot] = useState<{ tenants: TenantConfiguration[]; nowMs: number } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // The clicked column, tied to the window it was clicked in: another window shows no list. + const [selection, setSelection] = useState<{ days: number; key: string } | null>(null); + + const fetchTenants = useCallback(async () => { + setLoading(true); + setError(null); + try { + // config/all is a bare array of tenant configurations (deliberately untyped, D-043). + const tenants = await fetchJson(api.config.all(), getAccessToken); + setSnapshot({ tenants, nowMs: Date.now() }); + } catch (err) { + setError(apiErrorText(err, "Failed to load tenants")); + } finally { + setLoading(false); + } + }, [getAccessToken]); + + useEffect(() => { + const run = async () => { + await fetchTenants(); + }; + void run(); + }, [fetchTenants]); + + const growth = useMemo( + () => (snapshot ? customerGrowth(snapshot.tenants, days, snapshot.nowMs) : null), + [snapshot, days], + ); + const selectedKey = selection?.days === days ? selection.key : null; + const selectedBucket = growth?.buckets.find((b) => b.key === selectedKey) ?? null; + const chartRows = (growth?.buckets ?? []).map((b) => ({ + key: b.key, + startKey: b.startKey, + endKey: b.endKey, + count: b.count, + cumulative: b.cumulative, + })); + const formatCategory = (key: string) => formatDay(key, days <= 7 ? WEEKDAY : SHORT_DAY); + + const toggleBucket = (row: Record) => { + const key = row.key; + if (typeof key !== "string" || !row.count) return; + setSelection((current) => (current?.days === days && current.key === key ? null : { days, key })); + }; + + return ( +
+
+
+

Customers

+

+ {growth + ? `${formatDay(growth.startKey, RANGE_DAY)} – ${formatDay(growth.endKey, RANGE_DAY)}` + : "Tenant signups over time"} +

+
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {!growth || !snapshot ? ( + loading && ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) + ) : ( + <> +
+

New customers

+
+ {growth.windowCount.toLocaleString()} + {days === 1 ? "today" : `in the last ${days} days`} +
+
+ (selectedKey == null || row.key === selectedKey ? chartColors.primary : chartColors.muted)} + formatCategory={formatCategory} + renderTooltip={(row) => } + onCategoryClick={toggleBucket} + integerTicks + /> +
+ {selectedBucket && ( + setSelection(null)} + /> + )} +
+ +
+ + + + +
+ +
+
+

Customers gained

+ {growth.windowCount.toLocaleString()} +
+
+ formatCategory(String(key))} + hideLegend + integerTicks + /> +
+
+ + )} +
+ ); +} + +function BucketTooltip({ row, weekly, bestKey }: { row: Record; weekly: boolean; bestKey: string | null }) { + return ( +
+

{bucketTitle({ startKey: String(row.startKey), endKey: String(row.endKey) }, weekly)}

+

+ New customers + {String(row.count)} +

+ {!weekly && row.key === bestKey &&

● Best day

} +
+ ); +} + +function BucketSignups({ + bucket, + weekly, + nowMs, + onClose, +}: { + bucket: GrowthBucket; + weekly: boolean; + nowMs: number; + onClose: () => void; +}) { + return ( +
+
+ + {bucket.count === 1 ? "1 new customer" : `${bucket.count} new customers`} +
+
    + {bucket.signups.map(({ row, atMs }) => { + const edition = effectiveEdition(row, nowMs); + return ( +
  • + {signupTime(atMs, weekly)} + {row.domainName} + {row.companyName && {row.companyName}} + + {editionLabel(edition)} + +
  • + ); + })} +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx index 109abd15..11049d63 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx @@ -13,6 +13,7 @@ import { type ImePatternHealthResponse, type MatrixRow, } from "./imePatternHealthLogic"; +import { Stat } from "./Stat"; const CELL_CLASS: Record = { drift: "bg-red-100 text-red-800 font-semibold", @@ -193,13 +194,3 @@ export function SectionImePatternHealth() { ); } - -function Stat({ label, value, hint }: { label: string; value: string; hint: string }) { - return ( -
-

{label}

-

{value}

-

{hint}

-
- ); -} diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx index 66b5555a..cc28f5ae 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx @@ -20,6 +20,7 @@ import { type VerdictCalibrationPath, type VerdictCalibrationResponse, } from "./verdictCalibrationLogic"; +import { Stat } from "./Stat"; export function SectionVerdictCalibration() { const { getAccessToken } = useAuth(); @@ -161,16 +162,6 @@ export function SectionVerdictCalibration() { ); } -function Stat({ label, value, hint }: { label: string; value: string; hint: string }) { - return ( -
-

{label}

-

{value}

-

{hint}

-
- ); -} - function AlertRow({ alert }: { alert: VerdictCalibrationAlert }) { return (
  • diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/Stat.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/Stat.tsx new file mode 100644 index 00000000..50e971bd --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/Stat.tsx @@ -0,0 +1,10 @@ +/** A metrics tile: label, figure, one line of context. Shared by the GA metrics sections. */ +export function Stat({ label, value, hint }: { label: string; value: string; hint: string }) { + return ( +
    +

    {label}

    +

    {value}

    +

    {hint}

    +
    + ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/__tests__/customerGrowthLogic.test.ts b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/__tests__/customerGrowthLogic.test.ts new file mode 100644 index 00000000..b6e21214 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/__tests__/customerGrowthLogic.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { customerGrowth } from "../customerGrowthLogic"; + +interface Row { + tenantId: string; + domainName: string; + onboardedAt: string | null; +} + +let seq = 0; +function row(onboardedAt: string | null, domainName = "contoso.example"): Row { + seq++; + return { tenantId: `tenant-${seq}`, domainName, onboardedAt }; +} + +/** Tuesday 2026-10-20, 10:00 UTC. */ +const NOW = Date.parse("2026-10-20T10:00:00Z"); + +describe("customer growth window", () => { + it("covers today and the days before it, never the day before the window starts", () => { + const growth = customerGrowth( + [ + row("2026-10-13T23:59:59.9999999Z"), + row("2026-10-14T00:00:00Z"), + row("2026-10-20T09:00:00Z"), + row("2026-10-20T10:00:05Z"), // seconds after the client's clock, same UTC day + ], + 7, + NOW, + ); + expect(growth.startKey).toBe("2026-10-14"); + expect(growth.endKey).toBe("2026-10-20"); + expect(growth.windowCount).toBe(3); + expect(growth.buckets.map((b) => b.key)).toEqual([ + "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18", "2026-10-19", "2026-10-20", + ]); + expect(growth.buckets[0].count).toBe(1); + expect(growth.buckets[6].count).toBe(2); + }); + + it("puts a seven-digit fraction just before midnight on its own UTC day", () => { + const growth = customerGrowth([row("2026-10-15T23:59:59.9999999Z")], 7, NOW); + expect(growth.buckets.find((b) => b.key === "2026-10-15")?.count).toBe(1); + expect(growth.buckets.find((b) => b.key === "2026-10-16")?.count).toBe(0); + }); + + it("draws one bar per day up to 90 days, empty days included", () => { + for (const days of [1, 7, 30, 90]) { + const growth = customerGrowth([], days, NOW); + expect(growth.weekly, `days=${days}`).toBe(false); + expect(growth.buckets, `days=${days}`).toHaveLength(days); + expect(growth.buckets.every((b) => b.count === 0 && b.startKey === b.key && b.endKey === b.key)).toBe(true); + } + expect(customerGrowth([], 1, NOW).buckets[0].key).toBe("2026-10-20"); + }); + + it("switches to Monday-anchored weeks beyond 90 days, cut at both window edges", () => { + const growth = customerGrowth([row("2026-07-22T08:00:00Z"), row("2026-10-20T08:00:00Z")], 91, NOW); + expect(growth.weekly).toBe(true); + expect(growth.startKey).toBe("2026-07-22"); // a Wednesday + for (const b of growth.buckets) expect(new Date(b.key + "T00:00:00Z").getUTCDay()).toBe(1); + + const first = growth.buckets[0]; + expect(first).toMatchObject({ key: "2026-07-20", startKey: "2026-07-22", endKey: "2026-07-26", count: 1 }); + const last = growth.buckets[growth.buckets.length - 1]; + expect(last).toMatchObject({ key: "2026-10-19", startKey: "2026-10-19", endKey: "2026-10-20", count: 1 }); + expect(growth.buckets).toHaveLength(14); + }); + + it("keeps a week across the year boundary in one bucket", () => { + const sunday = Date.parse("2027-01-03T12:00:00Z"); + const growth = customerGrowth([row("2026-12-31T10:00:00Z"), row("2027-01-01T10:00:00Z")], 365, sunday); + const week = growth.buckets.find((b) => b.key === "2026-12-28"); + expect(week).toMatchObject({ startKey: "2026-12-28", endKey: "2027-01-03", count: 2 }); + }); + + it("accumulates from zero to the window count", () => { + const growth = customerGrowth( + [row("2026-09-25T08:00:00Z"), row("2026-10-01T08:00:00Z"), row("2026-10-01T09:00:00Z"), row("2026-10-19T08:00:00Z")], + 30, + NOW, + ); + const cumulative = growth.buckets.map((b) => b.cumulative); + for (let i = 1; i < cumulative.length; i++) expect(cumulative[i]).toBeGreaterThanOrEqual(cumulative[i - 1]); + expect(cumulative[0]).toBe(0); + expect(cumulative[cumulative.length - 1]).toBe(growth.windowCount); + expect(growth.windowCount).toBe(4); + }); +}); + +describe("customer growth figures", () => { + it("names the busiest day of the window, the most recent one on a tie", () => { + const growth = customerGrowth( + [ + row("2026-10-15T08:00:00Z"), row("2026-10-15T09:00:00Z"), + row("2026-10-18T08:00:00Z"), row("2026-10-18T09:00:00Z"), + row("2026-10-19T08:00:00Z"), + row("2026-09-01T08:00:00Z"), row("2026-09-01T09:00:00Z"), row("2026-09-01T10:00:00Z"), // outside 7 days + ], + 7, + NOW, + ); + expect(growth.bestDay).toEqual({ key: "2026-10-18", count: 2 }); + expect(customerGrowth([], 7, NOW).bestDay).toBeNull(); + }); + + it("takes the best day from days, not weeks, in the weekly view", () => { + const growth = customerGrowth( + [ + row("2026-10-05T08:00:00Z"), row("2026-10-06T08:00:00Z"), row("2026-10-07T08:00:00Z"), // one week, 3 days + row("2026-08-12T08:00:00Z"), row("2026-08-12T09:00:00Z"), + ], + 365, + NOW, + ); + expect(growth.weekly).toBe(true); + expect(growth.bestDay).toEqual({ key: "2026-08-12", count: 2 }); + }); + + it("averages per week over the window length", () => { + const daysBack = (n: number) => new Date(NOW - n * 86_400_000).toISOString(); + const signups = (count: number) => Array.from({ length: count }, (_, i) => row(daysBack(i % 7))); + expect(customerGrowth(signups(14), 7, NOW).weeklyAverage).toBe(14); + expect(customerGrowth(signups(30), 30, NOW).weeklyAverage).toBeCloseTo(7, 10); + expect(customerGrowth(signups(0), 365, NOW).weeklyAverage).toBe(0); + }); + + it("counts the last 7 days whatever the window", () => { + const rows = [row("2026-10-14T08:00:00Z"), row("2026-10-20T08:00:00Z"), row("2026-10-13T08:00:00Z"), row("2026-08-01T08:00:00Z")]; + const week = customerGrowth(rows, 7, NOW); + expect(week.last7Days).toBe(2); + expect(week.last7Days).toBe(week.windowCount); + expect(customerGrowth(rows, 90, NOW).last7Days).toBe(2); + expect(customerGrowth(rows, 1, NOW).last7Days).toBe(2); + }); + + it("counts only rows with a domain, and an unreadable timestamp in the total only", () => { + const growth = customerGrowth( + [ + row("2026-10-19T08:00:00Z", ""), // a default row from a read of an unknown tenant id + row(null), + row(""), + row("not-a-date"), + row("2026-10-19T09:00:00Z"), + ], + 7, + NOW, + ); + expect(growth.total).toBe(4); + expect(growth.windowCount).toBe(1); + expect(growth.last7Days).toBe(1); + }); + + it("lists every signup of a bucket, oldest first", () => { + const late = row("2026-10-19T17:00:00Z"); + const early = row("2026-10-19T06:00:00Z"); + const growth = customerGrowth([late, early], 7, NOW); + const monday = growth.buckets.find((b) => b.key === "2026-10-19"); + expect(monday?.signups.map((s) => s.row.tenantId)).toEqual([early.tenantId, late.tenantId]); + expect(monday?.signups[0].atMs).toBe(Date.parse("2026-10-19T06:00:00Z")); + }); + + it("returns zeros for no tenants", () => { + const growth = customerGrowth([], 30, NOW); + expect(growth).toMatchObject({ windowCount: 0, bestDay: null, weeklyAverage: 0, last7Days: 0, total: 0 }); + expect(growth.buckets).toHaveLength(30); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/customerGrowthLogic.ts b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/customerGrowthLogic.ts new file mode 100644 index 00000000..9a236d7e --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/customerGrowthLogic.ts @@ -0,0 +1,121 @@ +/** + * Tenant signups for the Customers section: one bucket per UTC calendar day of the window, or per + * Monday-anchored UTC week beyond 90 days (the Fleet Health timeline's rule), plus the window's + * figures. Pure — the section passes the tenant rows and the clock of its last fetch. + * + * A signup is a configuration row WITH a domain: only a member's first login writes DomainName, + * while reading an unknown tenant id creates a default row without one. The row's OnboardedAt is + * its insert time, so the series counts tenants that still exist (offboarding deletes the row). + */ +import { parseUtcMs } from "@/lib/osUpdateLive"; +import { COMMUNITY_WINDOW_CAP_DAYS, utcDateDaysAgo, utcWeekStart } from "@/lib/timeWindow"; + +/** The fields of a tenant row the signup series reads. */ +export interface SignupRow { + domainName?: string | null; + onboardedAt?: string | null; +} + +export interface Signup { + row: T; + atMs: number; +} + +export interface GrowthBucket { + /** First day of the bucket (yyyy-MM-dd): the day itself, or the Monday of its week. */ + key: string; + /** First and last day the bucket covers inside the window — a week is cut at both window edges. */ + startKey: string; + endKey: string; + count: number; + /** Signups from the window start through the end of this bucket. */ + cumulative: number; + /** Oldest first. */ + signups: Signup[]; +} + +export interface CustomerGrowth { + /** First and last day of the window (yyyy-MM-dd, UTC); the last day is today. */ + startKey: string; + endKey: string; + weekly: boolean; + buckets: GrowthBucket[]; + windowCount: number; + /** The window's busiest day, the most recent one on a tie; null without a signup in the window. */ + bestDay: { key: string; count: number } | null; + weeklyAverage: number; + /** Signups in the last 7 days, whatever the window. */ + last7Days: number; + /** Every signup, including those outside the window and those without a readable timestamp. */ + total: number; +} + +const LAST_DAYS = 7; + +function utcDayKey(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +/** + * Buckets the signups of the last `days` UTC calendar days, today included. Membership is decided + * on the UTC day, never on `atMs <= nowMs`, so a client clock a little behind the server still + * counts a signup made moments ago. + */ +export function customerGrowth(rows: readonly T[], days: number, nowMs: number): CustomerGrowth { + const now = new Date(nowMs); + const endKey = utcDayKey(nowMs); + const startKey = utcDateDaysAgo(days - 1, now); + const last7StartKey = utcDateDaysAgo(LAST_DAYS - 1, now); + + let total = 0; + let last7Days = 0; + const byDay = new Map[]>(); + for (const row of rows) { + if (!row.domainName) continue; + total++; + const atMs = parseUtcMs(row.onboardedAt); + if (atMs == null) continue; + const key = utcDayKey(atMs); + if (key > endKey) continue; + if (key >= last7StartKey) last7Days++; + if (key < startKey) continue; + const signups = byDay.get(key); + if (signups) signups.push({ row, atMs }); + else byDay.set(key, [{ row, atMs }]); + } + + const weekly = days > COMMUNITY_WINDOW_CAP_DAYS; + const buckets: GrowthBucket[] = []; + let windowCount = 0; + let bestDay: { key: string; count: number } | null = null; + for (let ago = days - 1; ago >= 0; ago--) { + const day = utcDateDaysAgo(ago, now); + const signups = (byDay.get(day) ?? []).sort((a, b) => a.atMs - b.atMs); + windowCount += signups.length; + // Oldest to newest, so ">=" hands a tie to the most recent day. + if (signups.length > 0 && signups.length >= (bestDay?.count ?? 0)) bestDay = { key: day, count: signups.length }; + + const key = weekly ? utcWeekStart(day) : day; + const last = buckets[buckets.length - 1]; + if (last && last.key === key) { + last.endKey = day; + last.count += signups.length; + last.cumulative = windowCount; + last.signups.push(...signups); + } else { + buckets.push({ key, startKey: day, endKey: day, count: signups.length, cumulative: windowCount, signups: [...signups] }); + } + } + + return { + startKey, + endKey, + weekly, + buckets, + windowCount, + bestDay, + weeklyAverage: (windowCount * 7) / days, + last7Days, + total, + }; +} diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/timelineBars.ts b/src/Web/autopilot-monitor-web/app/fleet-health/timelineBars.ts index 3d568db1..526edde8 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/timelineBars.ts +++ b/src/Web/autopilot-monitor-web/app/fleet-health/timelineBars.ts @@ -1,4 +1,4 @@ -import { COMMUNITY_WINDOW_CAP_DAYS } from "@/lib/timeWindow"; +import { COMMUNITY_WINDOW_CAP_DAYS, utcWeekStart } from "@/lib/timeWindow"; /** One server timeline point (UTC calendar day). */ export interface TimelinePoint { @@ -16,12 +16,6 @@ export interface TimelineBar { failed: number; } -function weekStartUtc(isoDate: string): string { - const d = new Date(isoDate + "T00:00:00Z"); - d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)); - return d.toISOString().slice(0, 10); -} - /** * Bars for the enrollments timeline. Up to 90 days one bar per day, labelled as before; beyond, * one bar per week so a year still fits the card, labelled with the week's first day — with the @@ -45,7 +39,7 @@ export function timelineBars(points: readonly TimelinePoint[], days: number): Ti const weeks = new Map(); for (const p of points) { - const week = weekStartUtc(p.date); + const week = utcWeekStart(p.date); const bucket = weeks.get(week) ?? { success: 0, failed: 0 }; bucket.success += p.success; bucket.failed += p.failed; diff --git a/src/Web/autopilot-monitor-web/components/charts/AppBarChart.tsx b/src/Web/autopilot-monitor-web/components/charts/AppBarChart.tsx index 2d8181dd..ebe96c4e 100644 --- a/src/Web/autopilot-monitor-web/components/charts/AppBarChart.tsx +++ b/src/Web/autopilot-monitor-web/components/charts/AppBarChart.tsx @@ -1,5 +1,6 @@ "use client"; +import type { ReactNode } from "react"; import { ResponsiveContainer, BarChart, @@ -22,10 +23,32 @@ export interface AppBarChartProps { /** Bar colour or a function to derive per-bar colour from the row. */ barColor?: string | ((row: Record) => string); valueFormatter?: (value: number) => string; + /** Category label for the ticks and the default tooltip (vertical bars); thins crowded ticks. */ + formatCategory?: (category: string) => string; + /** Replaces the tooltip content for the hovered row. */ + renderTooltip?: (row: Record) => ReactNode; + /** Click anywhere in a category's column (not just on its bar, which may be zero-height). */ + onCategoryClick?: (row: Record) => void; + /** Whole numbers only on the value axis. */ + integerTicks?: boolean; +} + +interface RenderedTooltipProps { + render: (row: Record) => ReactNode; + // Injected by recharts when it clones the content element. + active?: boolean; + payload?: ReadonlyArray<{ payload?: unknown }>; +} + +// Module level on purpose: recharts clones this element per hover, so its type must stay stable. +function RenderedTooltip({ render, active, payload }: RenderedTooltipProps) { + const row = active ? payload?.[0]?.payload : undefined; + if (!row || typeof row !== "object") return null; + return
    {render(row as Record)}
    ; } /** - * Reusable bar chart used by the App Dashboard detail page. + * Reusable bar chart used by the App Dashboard detail page and the GA Customers section. * Used for "Top Failure Codes" and "Installer Phase Breakdown" etc. */ export default function AppBarChart({ @@ -36,13 +59,27 @@ export default function AppBarChart({ horizontal = false, barColor = chartColors.primary, valueFormatter, + formatCategory, + renderTooltip, + onCategoryClick, + integerTicks, }: AppBarChartProps) { + const wholeNumbers = integerTicks ? { allowDecimals: false } : {}; return ( } | null) => { + const row = state?.activePayload?.[0]?.payload; + if (row && typeof row === "object") onCategoryClick(row as Record); + }, + } + : {})} > formatCategory(String(category)), minTickGap: 12 } + : {})} /> )} @@ -89,6 +131,8 @@ export default function AppBarChart({ contentStyle={tooltipStyle} cursor={{ fill: chartColors.gridLine, opacity: 0.3 }} formatter={(value: number) => [valueFormatter ? valueFormatter(value) : value, ""]} + {...(formatCategory && !horizontal ? { labelFormatter: (label: unknown) => formatCategory(String(label)) } : {})} + {...(renderTooltip ? { content: } : {})} /> {data.map((row, idx) => ( diff --git a/src/Web/autopilot-monitor-web/components/charts/AppLineChart.tsx b/src/Web/autopilot-monitor-web/components/charts/AppLineChart.tsx index 1563109e..edaf727b 100644 --- a/src/Web/autopilot-monitor-web/components/charts/AppLineChart.tsx +++ b/src/Web/autopilot-monitor-web/components/charts/AppLineChart.tsx @@ -26,6 +26,10 @@ export interface AppLineChartProps { yUnit?: string; yDomain?: [number | "auto", number | "auto"]; formatXTick?: (value: unknown) => string; + /** No legend — for a single series the card title already names. */ + hideLegend?: boolean; + /** Whole numbers only on the Y axis. */ + integerTicks?: boolean; } /** @@ -41,6 +45,8 @@ export default function AppLineChart({ yUnit, yDomain, formatXTick, + hideLegend, + integerTicks, }: AppLineChartProps) { return ( @@ -61,6 +67,7 @@ export default function AppLineChart({ domain={yDomain} tickFormatter={yUnit ? (v: number) => `${v}${yUnit}` : undefined} width={48} + {...(integerTicks ? { allowDecimals: false } : {})} /> - + {!hideLegend && } {series.map((s, idx) => ( { expect(utcDateDaysAgo(365, now)).toBe("2025-10-09"); }); + it("anchors a day on the Monday of its UTC week, across a year boundary too", () => { + expect(utcWeekStart("2026-10-19")).toBe("2026-10-19"); // Monday + expect(utcWeekStart("2026-10-25")).toBe("2026-10-19"); // Sunday + expect(utcWeekStart("2026-10-21")).toBe("2026-10-19"); + expect(utcWeekStart("2027-01-01")).toBe("2026-12-28"); // Friday → Monday of the previous year + }); + it("gives long windows their own progress estimate and leaves existing keys alone", () => { expect(windowProgressKey("fleet.lastFetchMs", 90)).toBe("fleet.lastFetchMs"); expect(windowProgressKey("fleet.lastFetchMs", 7)).toBe("fleet.lastFetchMs"); diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index eb2844fc..033d4707 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -262,6 +262,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ { id: "ga-metrics", label: "Metrics", icon: , items: [ + { id: "ga-customers", label: "Customers", href: route("/admin/metrics/customers") }, { id: "ga-platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics") }, { id: "ga-usage", label: "Platform Usage", href: route("/admin/metrics/usage") }, { id: "ga-active-users", label: "Active Users", href: "/admin/presence" }, diff --git a/src/Web/autopilot-monitor-web/lib/timeWindow.ts b/src/Web/autopilot-monitor-web/lib/timeWindow.ts index 9bc921e4..d1f11dce 100644 --- a/src/Web/autopilot-monitor-web/lib/timeWindow.ts +++ b/src/Web/autopilot-monitor-web/lib/timeWindow.ts @@ -42,6 +42,13 @@ export function utcDateDaysAgo(days: number, now: Date = new Date()): string { return d.toISOString().slice(0, 10); } +/** The Monday of the UTC week a calendar day (yyyy-MM-dd) falls in — the key of a weekly bar. */ +export function utcWeekStart(isoDate: string): string { + const d = new Date(isoDate + "T00:00:00Z"); + d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7)); + return d.toISOString().slice(0, 10); +} + /** * Progress-estimate storage key for a window: windows beyond the Community cap keep their own * estimate, so a year's compute time never becomes the estimate for a week (and existing keys stay). diff --git a/tools/quality/jscpd-baseline.json b/tools/quality/jscpd-baseline.json index 80e39232..f567b389 100644 --- a/tools/quality/jscpd-baseline.json +++ b/tools/quality/jscpd-baseline.json @@ -61,7 +61,6 @@ "cdcc39d2dad88d50": 1, "d171e886de82fa87": 1, "d2f7815d2bf8f83d": 1, - "d3005a21de8207a5": 1, "d6eea59c01bef2ef": 1, "d784153c023ebe3c": 1, "d9d223c00f790381": 1,