mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
+168
@@ -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).
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"cdcc39d2dad88d50": 1,
|
||||
"d171e886de82fa87": 1,
|
||||
"d2f7815d2bf8f83d": 1,
|
||||
"d3005a21de8207a5": 1,
|
||||
"d6eea59c01bef2ef": 1,
|
||||
"d784153c023ebe3c": 1,
|
||||
"d9d223c00f790381": 1,
|
||||
|
||||
Reference in New Issue
Block a user