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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-20 01:15:29 +02:00
parent 525be338b8
commit a7c3c16054
16 changed files with 651 additions and 32 deletions
@@ -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<MetricsSectionId, React.ComponentType> = {
"customers": SectionCustomers,
"platform-metrics": SectionAgentMetrics,
"usage": SectionPlatformUsage,
"mcp-usage": SectionMcpUsage,
@@ -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" },
@@ -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 <ClientRedirect to="/admin/metrics/platform-metrics" />;
return <ClientRedirect to="/admin/metrics/customers" />;
}
@@ -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 <div className="flex h-64 items-center justify-center text-sm text-gray-400">Loading chart…</div>;
}
// 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 (
<Suspense fallback={<div className="min-h-screen bg-gray-50" />}>
<CustomersContent />
</Suspense>
);
}
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<string | null>(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<TenantConfiguration[]>(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<string, unknown>) => {
const key = row.key;
if (typeof key !== "string" || !row.count) return;
setSelection((current) => (current?.days === days && current.key === key ? null : { days, key }));
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">Customers</h2>
<p className="text-sm text-gray-500">
{growth
? `${formatDay(growth.startKey, RANGE_DAY)} – ${formatDay(growth.endKey, RANGE_DAY)}`
: "Tenant signups over time"}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl options={RANGE_OPTIONS} value={days} onChange={setDays} />
<button
onClick={() => void fetchTenants()}
disabled={loading}
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
Refresh
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md px-4 py-3">{error}</div>
)}
{!growth || !snapshot ? (
loading && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[0, 1, 2, 3].map((i) => (
<CardSkeleton key={i} />
))}
</div>
)
) : (
<>
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-sm font-medium text-gray-500">New customers</h3>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-3xl font-semibold text-gray-900 tabular-nums">{growth.windowCount.toLocaleString()}</span>
<span className="text-sm text-gray-500">{days === 1 ? "today" : `in the last ${days} days`}</span>
</div>
<div className="mt-4">
<AppBarChart
data={chartRows}
categoryKey="key"
valueKey="count"
height={260}
barColor={(row) => (selectedKey == null || row.key === selectedKey ? chartColors.primary : chartColors.muted)}
formatCategory={formatCategory}
renderTooltip={(row) => <BucketTooltip row={row} weekly={growth.weekly} bestKey={growth.bestDay?.key ?? null} />}
onCategoryClick={toggleBucket}
integerTicks
/>
</div>
{selectedBucket && (
<BucketSignups
bucket={selectedBucket}
weekly={growth.weekly}
nowMs={snapshot.nowMs}
onClose={() => setSelection(null)}
/>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Stat
label="Best day"
value={growth.bestDay ? growth.bestDay.count.toLocaleString() : "—"}
hint={growth.bestDay ? formatDay(growth.bestDay.key, FULL_DAY) : "no signups in this window"}
/>
<Stat
label="Weekly average"
value={growth.weeklyAverage.toLocaleString(undefined, { maximumFractionDigits: 1 })}
hint="new per week"
/>
<Stat label="Last 7 days" value={growth.last7Days.toLocaleString()} hint="new" />
<Stat label="Total customers" value={growth.total.toLocaleString()} hint="all time" />
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-medium text-gray-500">Customers gained</h3>
<span className="text-2xl font-semibold text-gray-900 tabular-nums">{growth.windowCount.toLocaleString()}</span>
</div>
<div className="mt-4">
<AppLineChart
data={chartRows}
xKey="key"
series={[{ dataKey: "cumulative", label: "Customers gained", color: chartColors.primary }]}
height={220}
formatXTick={(key) => formatCategory(String(key))}
hideLegend
integerTicks
/>
</div>
</div>
</>
)}
</div>
);
}
function BucketTooltip({ row, weekly, bestKey }: { row: Record<string, unknown>; weekly: boolean; bestKey: string | null }) {
return (
<div className="space-y-1">
<p className="font-medium">{bucketTitle({ startKey: String(row.startKey), endKey: String(row.endKey) }, weekly)}</p>
<p className="flex justify-between gap-4">
<span>New customers</span>
<span className="font-semibold tabular-nums">{String(row.count)}</span>
</p>
{!weekly && row.key === bestKey && <p style={{ color: chartColors.success }}>● Best day</p>}
</div>
);
}
function BucketSignups({
bucket,
weekly,
nowMs,
onClose,
}: {
bucket: GrowthBucket<TenantConfiguration>;
weekly: boolean;
nowMs: number;
onClose: () => void;
}) {
return (
<div className="mt-4 border-t border-gray-100 pt-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<button
onClick={onClose}
title="Close the list"
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200"
>
<span aria-hidden="true">×</span>
<span>{bucketTitle(bucket, weekly)}</span>
</button>
<span className="text-xs text-gray-500">{bucket.count === 1 ? "1 new customer" : `${bucket.count} new customers`}</span>
</div>
<ul className="divide-y divide-gray-100">
{bucket.signups.map(({ row, atMs }) => {
const edition = effectiveEdition(row, nowMs);
return (
<li key={row.tenantId} className="flex items-center gap-3 py-1.5 text-sm">
<span className={`${weekly ? "w-32" : "w-20"} shrink-0 text-xs text-gray-500 tabular-nums`}>{signupTime(atMs, weekly)}</span>
<span className="min-w-0 truncate text-gray-900">{row.domainName}</span>
{row.companyName && <span className="hidden min-w-0 truncate text-gray-500 sm:inline">{row.companyName}</span>}
<span
className={`ml-auto inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${facetOption("plan", edition)?.badgeClass ?? ""}`}
>
{editionLabel(edition)}
</span>
</li>
);
})}
</ul>
</div>
);
}
@@ -13,6 +13,7 @@ import {
type ImePatternHealthResponse,
type MatrixRow,
} from "./imePatternHealthLogic";
import { Stat } from "./Stat";
const CELL_CLASS: Record<CellState, string> = {
drift: "bg-red-100 text-red-800 font-semibold",
@@ -193,13 +194,3 @@ export function SectionImePatternHealth() {
</div>
);
}
function Stat({ label, value, hint }: { label: string; value: string; hint: string }) {
return (
<div className="bg-white shadow rounded-lg p-4">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">{label}</p>
<p className="mt-1 text-2xl font-semibold text-gray-900">{value}</p>
<p className="text-xs text-gray-400">{hint}</p>
</div>
);
}
@@ -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 (
<div className="bg-white shadow rounded-lg p-4">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">{label}</p>
<p className="mt-1 text-2xl font-semibold text-gray-900">{value}</p>
<p className="text-xs text-gray-400">{hint}</p>
</div>
);
}
function AlertRow({ alert }: { alert: VerdictCalibrationAlert }) {
return (
<li className="py-2 text-sm">
@@ -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 (
<div className="bg-white shadow rounded-lg p-4">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">{label}</p>
<p className="mt-1 text-2xl font-semibold text-gray-900">{value}</p>
<p className="text-xs text-gray-400">{hint}</p>
</div>
);
}
@@ -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);
});
});
@@ -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<T> {
row: T;
atMs: number;
}
export interface GrowthBucket<T> {
/** 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<T>[];
}
export interface CustomerGrowth<T> {
/** First and last day of the window (yyyy-MM-dd, UTC); the last day is today. */
startKey: string;
endKey: string;
weekly: boolean;
buckets: GrowthBucket<T>[];
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<T extends SignupRow>(rows: readonly T[], days: number, nowMs: number): CustomerGrowth<T> {
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<string, Signup<T>[]>();
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<T>[] = [];
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,
};
}
@@ -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<string, { success: number; failed: number }>();
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;
@@ -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, unknown>) => 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<string, unknown>) => ReactNode;
/** Click anywhere in a category's column (not just on its bar, which may be zero-height). */
onCategoryClick?: (row: Record<string, unknown>) => void;
/** Whole numbers only on the value axis. */
integerTicks?: boolean;
}
interface RenderedTooltipProps {
render: (row: Record<string, unknown>) => 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 <div style={tooltipStyle}>{render(row as Record<string, unknown>)}</div>;
}
/**
* 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 (
<ResponsiveContainer width="100%" height={height}>
<BarChart
data={data}
layout={horizontal ? "vertical" : "horizontal"}
margin={{ top: 8, right: 16, left: horizontal ? 80 : 0, bottom: 4 }}
{...(onCategoryClick
? {
style: { cursor: "pointer" },
onClick: (state: { activePayload?: Array<{ payload?: unknown }> } | null) => {
const row = state?.activePayload?.[0]?.payload;
if (row && typeof row === "object") onCategoryClick(row as Record<string, unknown>);
},
}
: {})}
>
<CartesianGrid
stroke={chartColors.gridLine}
@@ -58,6 +95,7 @@ export default function AppBarChart({
axisLine={axisLine}
tickLine={false}
tickFormatter={valueFormatter}
{...wholeNumbers}
/>
<YAxis
type="category"
@@ -75,6 +113,9 @@ export default function AppBarChart({
tick={axisTick}
axisLine={axisLine}
tickLine={false}
{...(formatCategory
? { tickFormatter: (category: unknown) => formatCategory(String(category)), minTickGap: 12 }
: {})}
/>
<YAxis
tick={axisTick}
@@ -82,6 +123,7 @@ export default function AppBarChart({
tickLine={false}
tickFormatter={valueFormatter}
width={48}
{...wholeNumbers}
/>
</>
)}
@@ -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: <RenderedTooltip render={renderTooltip} /> } : {})}
/>
<Bar dataKey={valueKey} radius={[4, 4, 0, 0]}>
{data.map((row, idx) => (
@@ -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 (
<ResponsiveContainer width="100%" height={height}>
@@ -61,6 +67,7 @@ export default function AppLineChart({
domain={yDomain}
tickFormatter={yUnit ? (v: number) => `${v}${yUnit}` : undefined}
width={48}
{...(integerTicks ? { allowDecimals: false } : {})}
/>
<Tooltip
contentStyle={tooltipStyle}
@@ -72,7 +79,7 @@ export default function AppLineChart({
name,
]}
/>
<Legend wrapperStyle={legendStyle} iconType="plainline" />
{!hideLegend && <Legend wrapperStyle={legendStyle} iconType="plainline" />}
{series.map((s, idx) => (
<Line
key={s.dataKey}
@@ -6,6 +6,7 @@ import {
effectiveWindowDays,
parseWindowDays,
utcDateDaysAgo,
utcWeekStart,
windowProgressKey,
} from "@/lib/timeWindow";
@@ -43,6 +44,13 @@ describe("time window", () => {
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");
@@ -262,6 +262,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
{
id: "ga-metrics", label: "Metrics", icon: <ChartBarIcon />,
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" },
@@ -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).
-1
View File
@@ -61,7 +61,6 @@
"cdcc39d2dad88d50": 1,
"d171e886de82fa87": 1,
"d2f7815d2bf8f83d": 1,
"d3005a21de8207a5": 1,
"d6eea59c01bef2ef": 1,
"d784153c023ebe3c": 1,
"d9d223c00f790381": 1,