diff --git a/app/(app)/analytics/page.tsx b/app/(app)/analytics/page.tsx
new file mode 100644
index 00000000..2882d33a
--- /dev/null
+++ b/app/(app)/analytics/page.tsx
@@ -0,0 +1,771 @@
+import Link from "next/link";
+import { createClient } from "@/lib/supabase/server";
+import { ProjectLogo } from "@/components/project-logo";
+import { FontSparkline } from "@/components/font-sparkline";
+import { bucketLabel } from "@/lib/tracker/categorize";
+import { countryNameFromCode } from "@/lib/tracker/country";
+import { getOrCreateDefaultOrg, isOrgWideRole, listUserOrgs } from "@/lib/orgs";
+import { buildDailyAxis, toSeriesRow, utcDayAxis } from "@/lib/tracker/series";
+import {
+ computeTrend,
+ formatTrend,
+ portfolioVerdict,
+ type Trend,
+ type TrendDirection,
+} from "@/lib/tracker/trend";
+import {
+ TrackerAnalytics,
+ type TrackerListItem,
+} from "@/components/charts/tracker-analytics";
+import {
+ OTHER_KEY,
+ PortfolioTrend,
+ type PortfolioPoint,
+ type PortfolioSeries,
+} from "@/components/charts/portfolio-trend";
+
+export const metadata = { title: "Analytics" };
+
+// The per-project stats page is fixed at 30 days; the portfolio view is the
+// place you go to ask "over what horizon?", so the window is selectable.
+const RANGES = [7, 30, 90] as const;
+type Range = (typeof RANGES)[number];
+const DEFAULT_RANGE: Range = 30;
+
+// How many properties get their own band in the stacked chart. Everything else
+// collapses into "Other", derived by subtraction so the total stays exact.
+const CHART_BANDS = 6;
+
+// tracker_project_daily_series returns projects x days rows and PostgREST caps
+// a response at 1000, so only fetch daily detail for as many properties as fit
+// under a conservative budget. The rest still get totals and a trend — they
+// just can't show a sparkline.
+const DAILY_ROW_BUDGET = 900;
+
+type PortfolioProject = {
+ id: string;
+ name: string;
+ url: string;
+ status: string;
+ logo_url: string | null;
+ organization_id?: string | null;
+};
+
+type TotalsRow = {
+ project_id: string;
+ events: number | string;
+ ai: number | string;
+ bots: number | string;
+ prev_events: number | string;
+ prev_ai: number | string;
+ prev_bots: number | string;
+};
+
+type ProjectTotals = {
+ events: number;
+ ai: number;
+ bots: number;
+ prevEvents: number;
+ prevAi: number;
+ prevBots: number;
+};
+
+type ProjectRow = {
+ project: PortfolioProject;
+ totals: ProjectTotals;
+ trend: Trend;
+ samples: number[] | null;
+};
+
+export default async function PortfolioAnalyticsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ days?: string; org?: string }>;
+}) {
+ const { days: daysParam, org: orgParam } = await searchParams;
+ const days = parseRange(daysParam);
+
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ const { data: memberRows } = await supabase
+ .from("project_members")
+ .select("project_id")
+ .eq("user_id", user!.id);
+ const memberIds = (memberRows ?? []).map(
+ (r: { project_id: string }) => r.project_id,
+ );
+
+ let orgs = await listUserOrgs(supabase, user!.id);
+ if (orgs.length === 0) {
+ const org = await getOrCreateDefaultOrg({
+ userId: user!.id,
+ email: user!.email,
+ });
+ orgs = org.id ? [org] : [];
+ }
+ // Unlike the dashboard, this page defaults to every organization the viewer
+ // belongs to — "across all properties" is the whole point. `?org=` narrows.
+ const selectedOrg = orgs.find((org) => org.id === orgParam) ?? null;
+ const orgWideIds = orgs
+ .filter((org) => isOrgWideRole(org.role))
+ .map((org) => org.id);
+ const accessFilter = buildProjectAccessFilter(user!.id, memberIds, orgWideIds);
+
+ const orgSchemaReady = orgs.length > 0;
+ const projectColumns = orgSchemaReady
+ ? "id,name,url,status,logo_url,organization_id"
+ : "id,name,url,status,logo_url";
+
+ // Archived projects are excluded: they are gone from the portfolio by
+ // definition, and their historical traffic would drag the trend.
+ let projectsQuery = supabase
+ .from("projects")
+ .select(projectColumns)
+ .in("status", ["active", "paused"]);
+ if (selectedOrg?.id) {
+ projectsQuery = projectsQuery.eq("organization_id", selectedOrg.id);
+ }
+ const { data: projectsRaw } = await projectsQuery.or(accessFilter);
+ const projects = ((projectsRaw ?? []) as unknown) as PortfolioProject[];
+ const projectIds = projects.map((p) => p.id);
+
+ if (projectIds.length === 0) {
+ return (
+
+
+
+
+ No projects yet.{" "}
+
+ Add your first property
+ {" "}
+ to start seeing portfolio-wide analytics.
+
+
+
+ );
+ }
+
+ const [
+ seriesRes,
+ totalsRes,
+ bucketsRes,
+ mixRes,
+ pagesRes,
+ referrersRes,
+ actionsRes,
+ exitRes,
+ countriesRes,
+ citiesRes,
+ devicesRes,
+ ] = await Promise.all([
+ supabase.rpc("tracker_daily_series_multi", { p_projects: projectIds, days }),
+ supabase.rpc("tracker_project_totals", { p_projects: projectIds, days }),
+ supabase.rpc("tracker_bucket_totals_multi", { p_projects: projectIds, days, lim: 10 }),
+ supabase.rpc("tracker_event_mix_multi", { p_projects: projectIds, days }),
+ supabase.rpc("tracker_top_pages_multi", { p_projects: projectIds, days, lim: 10 }),
+ supabase.rpc("tracker_top_referrers_multi", { p_projects: projectIds, days, lim: 10 }),
+ supabase.rpc("tracker_top_actions_multi", { p_projects: projectIds, days, lim: 10 }),
+ supabase.rpc("tracker_top_exit_pages_multi", { p_projects: projectIds, days, lim: 10 }),
+ supabase.rpc("tracker_top_countries_multi", { p_projects: projectIds, days, lim: 10 }),
+ supabase.rpc("tracker_top_cities_multi", { p_projects: projectIds, days, lim: 10 }),
+ supabase.rpc("tracker_device_totals_multi", { p_projects: projectIds, days }),
+ ]);
+
+ const series = ((seriesRes.data ?? []) as Parameters[0][]).map(
+ toSeriesRow,
+ );
+ const daily = buildDailyAxis(series, days);
+
+ const totalsByProject = new Map();
+ for (const row of (totalsRes.data ?? []) as TotalsRow[]) {
+ totalsByProject.set(row.project_id, {
+ events: Number(row.events),
+ ai: Number(row.ai),
+ bots: Number(row.bots),
+ prevEvents: Number(row.prev_events),
+ prevAi: Number(row.prev_ai),
+ prevBots: Number(row.prev_bots),
+ });
+ }
+
+ const portfolio = sumTotals(totalsByProject.values());
+ const eventsTrend = computeTrend(portfolio.events, portfolio.prevEvents);
+ const aiTrend = computeTrend(portfolio.ai, portfolio.prevAi);
+ const botsTrend = computeTrend(portfolio.bots, portfolio.prevBots);
+ const otherTrend = computeTrend(
+ otherVisits(portfolio.events, portfolio.ai, portfolio.bots),
+ otherVisits(portfolio.prevEvents, portfolio.prevAi, portfolio.prevBots),
+ );
+
+ // Ranked by current-window volume: the biggest properties get the chart
+ // bands, and the daily-detail budget is spent on them first.
+ const ranked = projects
+ .map((project) => ({
+ project,
+ totals: totalsByProject.get(project.id) ?? emptyTotals(),
+ }))
+ .sort((a, b) => b.totals.events - a.totals.events);
+
+ const detailCount = Math.max(
+ CHART_BANDS,
+ Math.min(ranked.length, Math.floor(DAILY_ROW_BUDGET / days)),
+ );
+ const detailIds = ranked.slice(0, detailCount).map((r) => r.project.id);
+ const { data: perProjectRaw } = await supabase.rpc(
+ "tracker_project_daily_series",
+ { p_projects: detailIds, days },
+ );
+
+ const axis = utcDayAxis(days);
+ const dailyByProject = new Map>();
+ for (const id of detailIds) dailyByProject.set(id, new Map());
+ for (const row of (perProjectRaw ?? []) as Array<{
+ project_id: string;
+ day: string;
+ events: number | string;
+ }>) {
+ dailyByProject.get(row.project_id)?.set(row.day, Number(row.events));
+ }
+
+ const rows: ProjectRow[] = ranked.map(({ project, totals }) => {
+ const byDay = dailyByProject.get(project.id);
+ return {
+ project,
+ totals,
+ trend: computeTrend(totals.events, totals.prevEvents),
+ samples: byDay ? axis.map((day) => byDay.get(day) ?? 0) : null,
+ };
+ });
+
+ const withTraffic = rows.filter(
+ (r) => r.totals.events > 0 || r.totals.prevEvents > 0,
+ );
+ const verdict = portfolioVerdict(
+ eventsTrend,
+ withTraffic.map((r) => r.trend.direction),
+ );
+
+ // Stacked bands for the top properties; "Other" is the exact remainder of
+ // the full-portfolio series, so the stack always sums to the real total.
+ const banded = ranked.slice(0, CHART_BANDS).filter((r) => r.totals.events > 0);
+ const bandIds = banded.map((r) => r.project.id);
+ const chartSeries: PortfolioSeries[] = banded.map((r) => ({
+ key: r.project.id,
+ name: r.project.name,
+ }));
+ const hasOther = ranked.length > banded.length;
+ if (hasOther) chartSeries.push({ key: OTHER_KEY, name: "Other properties" });
+
+ const chartData: PortfolioPoint[] = daily.map((point) => {
+ const row: PortfolioPoint = { date: point.date };
+ let bandedTotal = 0;
+ for (const id of bandIds) {
+ const value = dailyByProject.get(id)?.get(point.date) ?? 0;
+ row[id] = value;
+ bandedTotal += value;
+ }
+ if (hasOther) row[OTHER_KEY] = Math.max(0, point.events - bandedTotal);
+ return row;
+ });
+
+ const topSources = (
+ (bucketsRes.data ?? []) as Array<{ bucket: string; total: number | string }>
+ ).map((r) => ({ label: bucketLabel(r.bucket), value: Number(r.total) }));
+
+ const mixItems = (
+ (mixRes.data ?? []) as Array<{ event: string; total: number | string }>
+ )
+ .map((r) => ({ label: eventLabel(r.event), value: Number(r.total) }))
+ .sort((a, b) => b.value - a.value)
+ .slice(0, 10);
+ const eventMix: TrackerListItem[] = mixItems.length
+ ? mixItems
+ : portfolio.events
+ ? [{ label: "Pageview", value: portfolio.events }]
+ : [];
+
+ const nameById = new Map(projects.map((p) => [p.id, p.name] as const));
+ const withProject = (projectId: string, label: string) =>
+ `${nameById.get(projectId) ?? "Unknown"} · ${label}`;
+
+ const topPages = (
+ (pagesRes.data ?? []) as Array<{
+ project_id: string;
+ page_path: string;
+ total: number | string;
+ }>
+ ).map((r) => ({
+ label: withProject(r.project_id, r.page_path || "/"),
+ value: Number(r.total),
+ }));
+
+ const exitPages = (
+ (exitRes.data ?? []) as Array<{
+ project_id: string;
+ page_path: string;
+ total: number | string;
+ }>
+ ).map((r) => ({
+ label: withProject(r.project_id, r.page_path || "/"),
+ value: Number(r.total),
+ }));
+
+ const topActions = (
+ (actionsRes.data ?? []) as Array<{
+ project_id: string;
+ event: string;
+ event_target: string;
+ total: number | string;
+ }>
+ ).map((r) => ({
+ label: withProject(r.project_id, `${eventLabel(r.event)} · ${r.event_target}`),
+ value: Number(r.total),
+ }));
+
+ const topReferrers = (
+ (referrersRes.data ?? []) as Array<{
+ referrer_host: string;
+ total: number | string;
+ }>
+ ).map((r) => ({ label: r.referrer_host, value: Number(r.total) }));
+
+ const topCountries = (
+ (countriesRes.data ?? []) as Array<{
+ country_code: string;
+ country_name: string;
+ total: number | string;
+ }>
+ )
+ .map((r) => ({
+ label:
+ r.country_name || r.country_code
+ ? `${r.country_name || countryNameFromCode(r.country_code) || r.country_code}${r.country_code ? ` (${r.country_code})` : ""}`
+ : "",
+ value: Number(r.total),
+ }))
+ .filter((it) => it.label);
+
+ const topCities = (
+ (citiesRes.data ?? []) as Array<{
+ city: string;
+ region_code: string;
+ region_name: string;
+ country_code: string;
+ country_name: string;
+ total: number | string;
+ }>
+ )
+ .map((r) => {
+ const region = r.region_code || r.region_name;
+ const country = r.country_code || r.country_name;
+ return {
+ label: [r.city, region, country].filter(Boolean).join(", "),
+ value: Number(r.total),
+ };
+ })
+ .filter((it) => it.label);
+
+ const deviceRows = (
+ (devicesRes.data ?? []) as Array<{
+ device_type: string;
+ browser: string;
+ os: string;
+ total: number | string;
+ }>
+ ).map((r) => ({
+ device_type: r.device_type,
+ browser: r.browser,
+ os: r.os,
+ count: Number(r.total),
+ }));
+
+ const topDevices = topDeviceItems(deviceRows, (row) =>
+ deviceTypeLabel(row.device_type),
+ );
+ const topBrowsers = topDeviceItems(deviceRows, (row) => row.browser);
+ const topOperatingSystems = topDeviceItems(deviceRows, (row) => row.os);
+
+ const missingSparklines = rows.filter(
+ (r) => r.samples === null && r.totals.events > 0,
+ ).length;
+
+ return (
+
+
+
+
+ {verdict}
+
+ Comparing the last {days} days against the {days} days before them,
+ across {projects.length}{" "}
+ {projects.length === 1 ? "property" : "properties"}
+ {selectedOrg ? ` in ${selectedOrg.name}` : ""}. Archived projects are
+ excluded.
+
+
+
+
+
+
+
+
+
+
+ {portfolio.events === 0 && portfolio.prevEvents === 0 ? (
+
+
+ No tracked events in this window. Enable the stats tracker on a
+ project to start collecting portfolio data.
+
+
+ ) : (
+ <>
+
+
+
+
Portfolio trend
+
+ Daily events, stacked by property.
+
+
+
+ {portfolio.events.toLocaleString()} events
+
+
+
+
+
+
+
+
+
By property
+
+ Each site against its own previous {days} days.
+
+
+
+ Manage projects
+
+
+
+ {missingSparklines > 0 && (
+
+ Sparklines are shown for the {detailCount} highest-traffic
+ properties; {missingSparklines} more{" "}
+ {missingSparklines === 1 ? "has" : "have"} totals only over this
+ window. Pick a shorter range to see more of them.
+
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
+
+function PageHeader({
+ days,
+ orgs,
+ selectedOrgId,
+}: {
+ days: Range;
+ orgs: Array<{ id: string; name: string }>;
+ selectedOrgId: string | null;
+}) {
+ return (
+
+
+
+
Portfolio analytics
+
+ Every property you can see, rolled into one view.
+
+
+
+ {RANGES.map((range) => (
+
+ {range}d
+
+ ))}
+
+
+ {orgs.length > 1 && (
+
+
+ All organizations
+
+ {orgs.map((org) => (
+
+ {org.name}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) {
+ if (rows.length === 0) {
+ return (
+ No properties to show.
+ );
+ }
+
+ return (
+
+
+
+
+ Property
+ Events
+ Previous
+ Change
+ Trend
+ AI / bots
+
+
+
+ {rows.map(({ project, totals, trend, samples }) => (
+
+
+
+
+
+
+ {project.name}
+
+
+ {project.url}
+ {project.status === "paused" ? " · paused" : ""}
+
+
+
+
+
+ {totals.events.toLocaleString()}
+
+
+ {totals.prevEvents.toLocaleString()}
+
+
+
+
+
+ {samples ? (
+
+ ) : (
+ —
+ )}
+
+
+ {totals.ai.toLocaleString()} / {totals.bots.toLocaleString()}
+
+
+ ))}
+
+
+
+ );
+}
+
+function TrendChip({ trend }: { trend: Trend }) {
+ return (
+
+ {formatTrend(trend)}
+
+ );
+}
+
+function TrendMetric({
+ label,
+ trend,
+ tone,
+}: {
+ label: string;
+ trend: Trend;
+ tone: "accent" | "pass" | "warn" | "muted";
+}) {
+ const color =
+ tone === "pass"
+ ? "text-green-600"
+ : tone === "warn"
+ ? "text-yellow-600"
+ : "text-[var(--color-foreground)]";
+ return (
+
+
{label}
+
+ {trend.current.toLocaleString()}
+
+
+
+ from {trend.previous.toLocaleString()}
+
+
+ );
+}
+
+function directionClass(direction: TrendDirection) {
+ if (direction === "up") return "bg-green-500/10 text-green-600";
+ if (direction === "down") return "bg-red-500/10 text-red-600";
+ return "bg-[var(--color-border)] text-[var(--color-muted)]";
+}
+
+function analyticsHref(days: Range, organizationId: string | null) {
+ const params = new URLSearchParams();
+ if (days !== DEFAULT_RANGE) params.set("days", String(days));
+ if (organizationId) params.set("org", organizationId);
+ const query = params.toString();
+ return `/analytics${query ? `?${query}` : ""}`;
+}
+
+function parseRange(value: string | undefined): Range {
+ const parsed = Number(value);
+ return (RANGES as readonly number[]).includes(parsed)
+ ? (parsed as Range)
+ : DEFAULT_RANGE;
+}
+
+function emptyTotals(): ProjectTotals {
+ return { events: 0, ai: 0, bots: 0, prevEvents: 0, prevAi: 0, prevBots: 0 };
+}
+
+function sumTotals(all: Iterable): ProjectTotals {
+ const out = emptyTotals();
+ for (const t of all) {
+ out.events += t.events;
+ out.ai += t.ai;
+ out.bots += t.bots;
+ out.prevEvents += t.prevEvents;
+ out.prevAi += t.prevAi;
+ out.prevBots += t.prevBots;
+ }
+ return out;
+}
+
+// Everything that isn't an AI referral or a bot crawl, matching the split the
+// per-project stats page uses.
+function otherVisits(events: number, ai: number, bots: number) {
+ return Math.max(0, events - ai - bots);
+}
+
+function buildProjectAccessFilter(
+ userId: string,
+ projectMemberIds: string[],
+ orgWideIds: string[],
+) {
+ const clauses = [`owner_id.eq.${userId}`];
+ if (projectMemberIds.length > 0)
+ clauses.push(`id.in.(${projectMemberIds.join(",")})`);
+ if (orgWideIds.length > 0)
+ clauses.push(`organization_id.in.(${orgWideIds.join(",")})`);
+ return clauses.join(",");
+}
+
+function topDeviceItems(
+ rows: Array<{ device_type: string; browser: string; os: string; count: number }>,
+ labelFor: (row: {
+ device_type: string;
+ browser: string;
+ os: string;
+ count: number;
+ }) => string,
+): TrackerListItem[] {
+ const map = new Map();
+ for (const row of rows) {
+ const label = labelFor(row);
+ if (!label) continue;
+ map.set(label, (map.get(label) ?? 0) + row.count);
+ }
+ return Array.from(map.entries())
+ .map(([label, value]) => ({ label, value }))
+ .sort((a, b) => b.value - a.value)
+ .slice(0, 10);
+}
+
+function deviceTypeLabel(deviceType: string) {
+ switch (deviceType) {
+ case "mobile":
+ return "Mobile";
+ case "tablet":
+ return "Tablet";
+ case "desktop":
+ return "Desktop";
+ case "bot":
+ return "Bot";
+ default:
+ return "";
+ }
+}
+
+function eventLabel(event: string) {
+ return event
+ .split("_")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index 2444d563..423bca56 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -35,6 +35,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
Dashboard
+ Analytics
Alerts
Recent
New
diff --git a/app/(app)/projects/[id]/stats/page.tsx b/app/(app)/projects/[id]/stats/page.tsx
index 215d1fd4..003b1d92 100644
--- a/app/(app)/projects/[id]/stats/page.tsx
+++ b/app/(app)/projects/[id]/stats/page.tsx
@@ -8,9 +8,9 @@ import { DEFAULT_PROJECT_ENGINES, type Engine } from "@/lib/credits";
import type { ProjectStatus } from "@/app/actions/projects";
import {
TrackerAnalytics,
- type TrackerDailyPoint,
type TrackerListItem,
} from "@/components/charts/tracker-analytics";
+import { buildDailyAxis, toSeriesRow } from "@/lib/tracker/series";
import { InstallSnippet } from "./install-snippet";
import { TrackerToggle } from "./tracker-toggle";
import { CareersToggle } from "./careers-toggle";
@@ -21,15 +21,8 @@ import { getOrMintInstallationToken } from "@/lib/github/installations";
import { listInstallationRepos } from "@/lib/github/app";
// Shape returned by the tracker_daily_series RPC. bigint columns arrive as
-// strings over PostgREST, so we coerce with Number() at the call site.
-type SeriesRow = {
- day: string;
- pageviews: number | string;
- interactions: number | string;
- ai: number | string;
- bots: number | string;
- events: number | string;
-};
+// strings over PostgREST; toSeriesRow() coerces them.
+type SeriesRow = Parameters[0];
type BoundRepo = {
id: string;
full_name: string;
@@ -91,15 +84,8 @@ export default async function ProjectStatsPage({
supabase.rpc("tracker_device_totals", { p_project: id, days }),
]);
- const series = ((seriesRes.data ?? []) as SeriesRow[]).map((r) => ({
- day: r.day,
- pageviews: Number(r.pageviews),
- interactions: Number(r.interactions),
- ai: Number(r.ai),
- bots: Number(r.bots),
- events: Number(r.events),
- }));
- const daily = buildDaily(series);
+ const series = ((seriesRes.data ?? []) as SeriesRow[]).map(toSeriesRow);
+ const daily = buildDailyAxis(series, WINDOW_DAYS);
// Headline metrics come straight from the series so they stay exact even
// though Top sources below is truncated to the top 10 buckets. "Other visits"
@@ -443,55 +429,6 @@ function ConnectedRepos({
);
}
-// Map the per-day series onto a zero-filled WINDOW_DAYS axis (UTC) so gaps
-// render as flat spans rather than being skipped. Days beyond the RPC's own
-// window (should be none) are ignored.
-function buildDaily(
- series: Array<{
- day: string;
- pageviews: number;
- interactions: number;
- ai: number;
- bots: number;
- events: number;
- }>,
-): TrackerDailyPoint[] {
- const start = new Date();
- start.setUTCHours(0, 0, 0, 0);
- start.setUTCDate(start.getUTCDate() - WINDOW_DAYS + 1);
-
- const byDay = new Map();
- for (let i = 0; i < WINDOW_DAYS; i++) {
- const d = new Date(start);
- d.setUTCDate(start.getUTCDate() + i);
- const date = d.toISOString().slice(0, 10);
- byDay.set(date, {
- date,
- events: 0,
- pageviews: 0,
- interactions: 0,
- ai: 0,
- bots: 0,
- });
- }
-
- for (const row of series) {
- const point = byDay.get(row.day);
- if (!point) continue;
- point.pageviews += row.pageviews;
- point.interactions += row.interactions;
- point.ai += row.ai;
- point.bots += row.bots;
- point.events += row.events;
- }
-
- for (const point of byDay.values()) {
- if (point.events === 0) point.events = point.pageviews + point.interactions;
- }
-
- return Array.from(byDay.values());
-}
-
function topDeviceItems(
rows: DeviceRow[],
labelFor: (row: DeviceRow) => string,
diff --git a/components/charts/portfolio-trend.tsx b/components/charts/portfolio-trend.tsx
new file mode 100644
index 00000000..0fb578a1
--- /dev/null
+++ b/components/charts/portfolio-trend.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ Legend,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+
+// One stacked band per property, so the total height is the portfolio and each
+// band shows which site is actually moving it. Series keys are project ids
+// (plus the synthetic OTHER_KEY band) because project names are not unique.
+export type PortfolioSeries = {
+ key: string;
+ name: string;
+};
+
+export type PortfolioPoint = { date: string } & Record;
+
+export const OTHER_KEY = "__other";
+
+const COLORS = [
+ "var(--color-accent)",
+ "#60a5fa",
+ "var(--color-warn)",
+ "#f472b6",
+ "#a78bfa",
+ "#2dd4bf",
+];
+
+const OTHER_COLOR = "var(--color-muted)";
+
+export function PortfolioTrend({
+ data,
+ series,
+}: {
+ data: PortfolioPoint[];
+ series: PortfolioSeries[];
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {series.map((entry, index) => {
+ const color =
+ entry.key === OTHER_KEY
+ ? OTHER_COLOR
+ : COLORS[index % COLORS.length];
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+const tooltipStyle = {
+ background: "var(--color-card)",
+ border: "1px solid var(--color-border)",
+ borderRadius: 8,
+ fontSize: 12,
+};
+
+function formatDate(value: string) {
+ return new Date(value).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function formatLongDate(value: unknown) {
+ return new Date(String(value)).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
diff --git a/lib/tracker/series.ts b/lib/tracker/series.ts
new file mode 100644
index 00000000..752458ab
--- /dev/null
+++ b/lib/tracker/series.ts
@@ -0,0 +1,93 @@
+// Shared shaping for the tracker_daily_series / tracker_daily_series_multi
+// RPCs. Both the per-project stats page and the portfolio analytics page map
+// the sparse per-day rows onto a zero-filled UTC axis so gaps render as flat
+// spans rather than being skipped entirely.
+
+export type TrackerSeriesRow = {
+ day: string;
+ pageviews: number;
+ interactions: number;
+ ai: number;
+ bots: number;
+ events: number;
+};
+
+export type TrackerDailyPointShape = {
+ date: string;
+ events: number;
+ pageviews: number;
+ interactions: number;
+ ai: number;
+ bots: number;
+};
+
+// Coerce a raw RPC row. bigint columns arrive as strings over PostgREST.
+export function toSeriesRow(row: {
+ day: string;
+ pageviews: number | string;
+ interactions: number | string;
+ ai: number | string;
+ bots: number | string;
+ events: number | string;
+}): TrackerSeriesRow {
+ return {
+ day: row.day,
+ pageviews: Number(row.pageviews),
+ interactions: Number(row.interactions),
+ ai: Number(row.ai),
+ bots: Number(row.bots),
+ events: Number(row.events),
+ };
+}
+
+// The last `days` UTC calendar days, oldest first, as YYYY-MM-DD.
+export function utcDayAxis(days: number, now = new Date()): string[] {
+ const span = Math.max(1, days);
+ const start = new Date(now);
+ start.setUTCHours(0, 0, 0, 0);
+ start.setUTCDate(start.getUTCDate() - span + 1);
+
+ return Array.from({ length: span }, (_, i) => {
+ const d = new Date(start);
+ d.setUTCDate(start.getUTCDate() + i);
+ return d.toISOString().slice(0, 10);
+ });
+}
+
+// Map the per-day series onto a zero-filled `days`-wide axis. Rows outside the
+// window (should be none — the RPC applies the same window) are ignored.
+export function buildDailyAxis(
+ series: TrackerSeriesRow[],
+ days: number,
+ now = new Date(),
+): TrackerDailyPointShape[] {
+ const byDay = new Map();
+ for (const date of utcDayAxis(days, now)) {
+ byDay.set(date, {
+ date,
+ events: 0,
+ pageviews: 0,
+ interactions: 0,
+ ai: 0,
+ bots: 0,
+ });
+ }
+
+ for (const row of series) {
+ const point = byDay.get(row.day);
+ if (!point) continue;
+ point.pageviews += row.pageviews;
+ point.interactions += row.interactions;
+ point.ai += row.ai;
+ point.bots += row.bots;
+ point.events += row.events;
+ }
+
+ // Older rollups predate the bucket table, so `events` can be 0 on a day that
+ // clearly had traffic. Fall back to the event table's own totals.
+ for (const point of byDay.values()) {
+ if (point.events === 0) point.events = point.pageviews + point.interactions;
+ }
+
+ return Array.from(byDay.values());
+}
diff --git a/lib/tracker/trend.ts b/lib/tracker/trend.ts
new file mode 100644
index 00000000..c195adf6
--- /dev/null
+++ b/lib/tracker/trend.ts
@@ -0,0 +1,103 @@
+// Window-over-window trend maths for the portfolio analytics page.
+//
+// Every figure on that page compares the current N-day window against the N
+// days immediately before it, so "is this going up, down, or sideways?" has a
+// single consistent definition across the headline metrics and the per-project
+// table.
+
+export type TrendDirection = "up" | "down" | "flat";
+
+export type Trend = {
+ current: number;
+ previous: number;
+ /** Percent change vs the previous window; null when there is no baseline. */
+ changePct: number | null;
+ direction: TrendDirection;
+ /**
+ * Both windows are small enough that the percentage is mostly noise. The
+ * direction is still reported honestly — this just lets the UI soften how
+ * loudly it says so.
+ */
+ lowVolume: boolean;
+};
+
+/** Below this much movement, a window counts as sideways rather than a trend. */
+export const FLAT_BAND_PCT = 5;
+
+/** Under this many events in both windows, percentages swing wildly. */
+export const LOW_VOLUME_EVENTS = 20;
+
+export function computeTrend(
+ current: number,
+ previous: number,
+ { flatBandPct = FLAT_BAND_PCT, lowVolumeAt = LOW_VOLUME_EVENTS } = {},
+): Trend {
+ const lowVolume = Math.max(current, previous) < lowVolumeAt;
+
+ // No baseline: percent change is undefined rather than infinite. Traffic
+ // appearing where there was none is still a genuine "up".
+ if (previous === 0) {
+ return {
+ current,
+ previous,
+ changePct: null,
+ direction: current > 0 ? "up" : "flat",
+ lowVolume,
+ };
+ }
+
+ const changePct = ((current - previous) / previous) * 100;
+ const direction: TrendDirection =
+ Math.abs(changePct) < flatBandPct ? "flat" : changePct > 0 ? "up" : "down";
+
+ return { current, previous, changePct, direction, lowVolume };
+}
+
+export function trendArrow(direction: TrendDirection) {
+ return direction === "up" ? "▲" : direction === "down" ? "▼" : "▬";
+}
+
+/** Short label for a trend chip, e.g. "▲ 12.4%", "▬ flat", "▲ new". */
+export function formatTrend(trend: Trend) {
+ const arrow = trendArrow(trend.direction);
+ if (trend.changePct === null) {
+ return trend.current > 0 ? `${arrow} new` : `${arrow} no data`;
+ }
+ const magnitude = Math.abs(trend.changePct);
+ const rounded = magnitude >= 100 ? Math.round(magnitude) : Number(magnitude.toFixed(1));
+ return `${arrow} ${rounded.toLocaleString()}%`;
+}
+
+/**
+ * The one-line verdict for the whole portfolio. Uses the aggregate trend but
+ * also counts how the individual properties split, because "up 8% overall"
+ * means something different when one site carries it than when nine do.
+ */
+export function portfolioVerdict(
+ overall: Trend,
+ perProject: TrendDirection[],
+): string {
+ const up = perProject.filter((d) => d === "up").length;
+ const down = perProject.filter((d) => d === "down").length;
+ const flat = perProject.filter((d) => d === "flat").length;
+ const total = perProject.length;
+
+ if (total === 0) return "No traffic recorded yet across your properties.";
+
+ const split = `${up} up · ${flat} sideways · ${down} down`;
+ const headline =
+ overall.direction === "up"
+ ? "Your portfolio is growing"
+ : overall.direction === "down"
+ ? "Your portfolio is shrinking"
+ : "Your portfolio is holding steady";
+
+ const change =
+ overall.changePct === null
+ ? ""
+ : ` ${formatTrend(overall).replace(/^[▲▼▬] /, overall.direction === "down" ? "down " : overall.direction === "up" ? "up " : "within ")}`;
+
+ return `${headline}${change} vs the previous window — ${split} of ${total} ${
+ total === 1 ? "property" : "properties"
+ }.`;
+}
diff --git a/supabase/migrations/20260809120000_tracker_stats_rpc_multi.sql b/supabase/migrations/20260809120000_tracker_stats_rpc_multi.sql
new file mode 100644
index 00000000..2dae5362
--- /dev/null
+++ b/supabase/migrations/20260809120000_tracker_stats_rpc_multi.sql
@@ -0,0 +1,362 @@
+-- Portfolio stats: multi-project variants of the tracker_* aggregates that
+-- power /projects/:id/stats, for the global /analytics page.
+--
+-- WHY: the global page needs the same breakdowns rolled up across every
+-- project the viewer can see. Calling the single-project RPCs once per project
+-- and merging in JS would be N round trips and would re-truncate every top-N
+-- list before merging (a bucket ranked 11th on each of ten sites can easily be
+-- the #1 bucket overall, yet would never appear). Aggregating the whole set in
+-- one query keeps the ranking correct and the row count bounded.
+--
+-- security invoker, exactly like the single-project RPCs: RLS on the tracker_*
+-- tables already scopes SELECT to the project owner / members, so passing an
+-- id the caller can't see contributes nothing rather than leaking.
+--
+-- Row-cap discipline (PostgREST caps a response at 1000 rows): every function
+-- here returns at most (days) rows, (lim) rows, or one row per project. The
+-- one exception is tracker_project_daily_series, which is projects x days —
+-- see the note on that function.
+--
+-- Days are UTC calendar days to match the axis the pages build in JS.
+-- Apply via psql over the pooler / MCP (prod migration history diverged),
+-- not `db push`.
+
+-- Per-day series for the 4 chart lines, summed over every project in the set.
+create or replace function public.tracker_daily_series_multi(
+ p_projects uuid[],
+ days integer default 30
+)
+returns table (
+ day date,
+ pageviews bigint,
+ interactions bigint,
+ ai bigint,
+ bots bigint,
+ events bigint
+)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ with since as (
+ select ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1)) as d
+ ),
+ ev as (
+ select day,
+ sum(count) filter (where event = 'pageview')::bigint as pageviews,
+ sum(count) filter (where event <> 'pageview')::bigint as interactions
+ from public.tracker_event_daily_stats
+ where project_id = any(p_projects) and day >= (select d from since)
+ group by day
+ ),
+ bk as (
+ select day,
+ sum(count) filter (where bucket like 'ai_referral:%')::bigint as ai,
+ sum(count) filter (where bucket like 'bot:%')::bigint as bots,
+ sum(count)::bigint as events
+ from public.tracker_daily_stats
+ where project_id = any(p_projects) and day >= (select d from since)
+ group by day
+ )
+ select coalesce(ev.day, bk.day) as day,
+ coalesce(ev.pageviews, 0) as pageviews,
+ coalesce(ev.interactions, 0) as interactions,
+ coalesce(bk.ai, 0) as ai,
+ coalesce(bk.bots, 0) as bots,
+ coalesce(bk.events, 0) as events
+ from ev
+ full outer join bk on ev.day = bk.day;
+$$;
+
+-- Per-project totals for the current window AND the window immediately before
+-- it, so the page can say whether each property is up, down, or sideways.
+-- One row per project that has any traffic in either window.
+create or replace function public.tracker_project_totals(
+ p_projects uuid[],
+ days integer default 30
+)
+returns table (
+ project_id uuid,
+ events bigint,
+ ai bigint,
+ bots bigint,
+ prev_events bigint,
+ prev_ai bigint,
+ prev_bots bigint
+)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ -- The two windows are adjacent and equal-length: prev_end is the day before
+ -- cur_start, so every row scanned falls in exactly one of them.
+ with bounds as (
+ select (today - (n - 1)) as cur_start,
+ (today - (2 * n - 1)) as prev_start,
+ (today - n) as prev_end
+ from (
+ select greatest(coalesce(days, 30), 1) as n,
+ (now() at time zone 'UTC')::date as today
+ ) win
+ )
+ select s.project_id,
+ coalesce(sum(s.count) filter (where s.day >= b.cur_start), 0)::bigint as events,
+ coalesce(sum(s.count) filter (where s.day >= b.cur_start and s.bucket like 'ai_referral:%'), 0)::bigint as ai,
+ coalesce(sum(s.count) filter (where s.day >= b.cur_start and s.bucket like 'bot:%'), 0)::bigint as bots,
+ coalesce(sum(s.count) filter (where s.day <= b.prev_end), 0)::bigint as prev_events,
+ coalesce(sum(s.count) filter (where s.day <= b.prev_end and s.bucket like 'ai_referral:%'), 0)::bigint as prev_ai,
+ coalesce(sum(s.count) filter (where s.day <= b.prev_end and s.bucket like 'bot:%'), 0)::bigint as prev_bots
+ from public.tracker_daily_stats s
+ cross join bounds b
+ where s.project_id = any(p_projects)
+ and s.day >= b.prev_start
+ group by s.project_id;
+$$;
+
+-- Per-project daily event counts, for the stacked portfolio chart and the
+-- per-project sparklines.
+--
+-- CAUTION: this one returns projects x days rows, so it is the only function
+-- here that can approach PostgREST's 1000-row cap. Callers must pass a
+-- narrowed id list (the page passes only the handful of projects it charts
+-- individually and derives the "Other" band by subtracting them from
+-- tracker_daily_series_multi, which stays exact for the full set).
+create or replace function public.tracker_project_daily_series(
+ p_projects uuid[],
+ days integer default 30
+)
+returns table (project_id uuid, day date, events bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select project_id, day, sum(count)::bigint as events
+ from public.tracker_daily_stats
+ where project_id = any(p_projects)
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by project_id, day;
+$$;
+
+-- Top buckets across the set (drives the Top sources breakdown).
+create or replace function public.tracker_bucket_totals_multi(
+ p_projects uuid[],
+ days integer default 30,
+ lim integer default 10
+)
+returns table (bucket text, total bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select bucket, sum(count)::bigint as total
+ from public.tracker_daily_stats
+ where project_id = any(p_projects)
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by bucket
+ order by total desc
+ limit greatest(coalesce(lim, 10), 1);
+$$;
+
+-- Event mix across the set.
+create or replace function public.tracker_event_mix_multi(
+ p_projects uuid[],
+ days integer default 30
+)
+returns table (event text, total bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select event, sum(count)::bigint as total
+ from public.tracker_event_daily_stats
+ where project_id = any(p_projects)
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by event
+ order by total desc;
+$$;
+
+-- Top pageview paths. Grouped by project as well as path: '/' exists on every
+-- site, so collapsing across projects would produce a meaningless "/" row. The
+-- page labels each entry with the project it belongs to.
+create or replace function public.tracker_top_pages_multi(
+ p_projects uuid[],
+ days integer default 30,
+ lim integer default 10
+)
+returns table (project_id uuid, page_path text, total bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select project_id,
+ coalesce(nullif(page_path, ''), '/') as page_path,
+ sum(count)::bigint as total
+ from public.tracker_event_daily_stats
+ where project_id = any(p_projects)
+ and event = 'pageview'
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by project_id, 2
+ order by total desc
+ limit greatest(coalesce(lim, 10), 1);
+$$;
+
+-- Top exit pages, grouped by project for the same reason as top pages.
+create or replace function public.tracker_top_exit_pages_multi(
+ p_projects uuid[],
+ days integer default 30,
+ lim integer default 10
+)
+returns table (project_id uuid, page_path text, total bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select project_id,
+ coalesce(nullif(page_path, ''), '/') as page_path,
+ sum(count)::bigint as total
+ from public.tracker_exit_daily_stats
+ where project_id = any(p_projects)
+ and count > 0
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by project_id, 2
+ order by total desc
+ limit greatest(coalesce(lim, 10), 1);
+$$;
+
+-- Top external referrer hosts across the set. Referrer hosts are genuinely
+-- global (news.ycombinator.com sending to three of your sites is one story),
+-- so these collapse across projects.
+create or replace function public.tracker_top_referrers_multi(
+ p_projects uuid[],
+ days integer default 30,
+ lim integer default 10
+)
+returns table (referrer_host text, total bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select referrer_host, sum(count)::bigint as total
+ from public.tracker_event_daily_stats
+ where project_id = any(p_projects)
+ and coalesce(referrer_host, '') <> ''
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by referrer_host
+ order by total desc
+ limit greatest(coalesce(lim, 10), 1);
+$$;
+
+-- Top interactions across the set, grouped by project so a generic target
+-- label ("signup") stays attributable to the site it fired on.
+create or replace function public.tracker_top_actions_multi(
+ p_projects uuid[],
+ days integer default 30,
+ lim integer default 10
+)
+returns table (project_id uuid, event text, event_target text, total bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select project_id, event, event_target, sum(count)::bigint as total
+ from public.tracker_event_daily_stats
+ where project_id = any(p_projects)
+ and event <> 'pageview'
+ and coalesce(event_target, '') <> ''
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by project_id, event, event_target
+ order by total desc
+ limit greatest(coalesce(lim, 10), 1);
+$$;
+
+-- Top countries across the set.
+create or replace function public.tracker_top_countries_multi(
+ p_projects uuid[],
+ days integer default 30,
+ lim integer default 10
+)
+returns table (country_code text, country_name text, total bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select country_code, max(country_name) as country_name, sum(count)::bigint as total
+ from public.tracker_geo_daily_stats
+ where project_id = any(p_projects)
+ and (coalesce(country_code, '') <> '' or coalesce(country_name, '') <> '')
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by country_code
+ order by total desc
+ limit greatest(coalesce(lim, 10), 1);
+$$;
+
+-- Top cities across the set.
+create or replace function public.tracker_top_cities_multi(
+ p_projects uuid[],
+ days integer default 30,
+ lim integer default 10
+)
+returns table (
+ city text,
+ region_code text,
+ region_name text,
+ country_code text,
+ country_name text,
+ total bigint
+)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select city, region_code, region_name, country_code, country_name,
+ sum(count)::bigint as total
+ from public.tracker_geo_daily_stats
+ where project_id = any(p_projects)
+ and coalesce(city, '') <> ''
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by city, region_code, region_name, country_code, country_name
+ order by total desc
+ limit greatest(coalesce(lim, 10), 1);
+$$;
+
+-- Device / browser / OS totals across the set.
+create or replace function public.tracker_device_totals_multi(
+ p_projects uuid[],
+ days integer default 30
+)
+returns table (device_type text, browser text, os text, total bigint)
+language sql
+stable
+security invoker
+set search_path = public
+as $$
+ select device_type, browser, os, sum(count)::bigint as total
+ from public.tracker_device_daily_stats
+ where project_id = any(p_projects)
+ and day >= ((now() at time zone 'UTC')::date - (greatest(coalesce(days, 30), 1) - 1))
+ group by device_type, browser, os;
+$$;
+
+grant execute on function public.tracker_daily_series_multi(uuid[], integer) to authenticated, service_role;
+grant execute on function public.tracker_project_totals(uuid[], integer) to authenticated, service_role;
+grant execute on function public.tracker_project_daily_series(uuid[], integer) to authenticated, service_role;
+grant execute on function public.tracker_bucket_totals_multi(uuid[], integer, integer) to authenticated, service_role;
+grant execute on function public.tracker_event_mix_multi(uuid[], integer) to authenticated, service_role;
+grant execute on function public.tracker_top_pages_multi(uuid[], integer, integer) to authenticated, service_role;
+grant execute on function public.tracker_top_exit_pages_multi(uuid[], integer, integer) to authenticated, service_role;
+grant execute on function public.tracker_top_referrers_multi(uuid[], integer, integer) to authenticated, service_role;
+grant execute on function public.tracker_top_actions_multi(uuid[], integer, integer) to authenticated, service_role;
+grant execute on function public.tracker_top_countries_multi(uuid[], integer, integer) to authenticated, service_role;
+grant execute on function public.tracker_top_cities_multi(uuid[], integer, integer) to authenticated, service_role;
+grant execute on function public.tracker_device_totals_multi(uuid[], integer) to authenticated, service_role;
diff --git a/tests/portfolio-analytics.test.ts b/tests/portfolio-analytics.test.ts
new file mode 100644
index 00000000..d78d9553
--- /dev/null
+++ b/tests/portfolio-analytics.test.ts
@@ -0,0 +1,194 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildDailyAxis,
+ toSeriesRow,
+ utcDayAxis,
+ type TrackerSeriesRow,
+} from "@/lib/tracker/series";
+import {
+ computeTrend,
+ formatTrend,
+ portfolioVerdict,
+ type TrendDirection,
+} from "@/lib/tracker/trend";
+
+// The portfolio page's whole job is answering "up, down, or sideways?", so the
+// arithmetic behind that verdict is what these cover. Everything here is pure —
+// no database, no clock (both helpers take an injectable `now`).
+
+const NOW = new Date("2026-08-09T12:00:00Z");
+
+function row(day: string, over: Partial = {}): TrackerSeriesRow {
+ return {
+ day,
+ pageviews: 0,
+ interactions: 0,
+ ai: 0,
+ bots: 0,
+ events: 0,
+ ...over,
+ };
+}
+
+describe("computeTrend", () => {
+ it("calls a clear rise up and a clear fall down", () => {
+ expect(computeTrend(150, 100).direction).toBe("up");
+ expect(computeTrend(150, 100).changePct).toBeCloseTo(50);
+ expect(computeTrend(50, 100).direction).toBe("down");
+ expect(computeTrend(50, 100).changePct).toBeCloseTo(-50);
+ });
+
+ it("treats movement inside the flat band as sideways", () => {
+ // 4% either way is noise, not a trend.
+ expect(computeTrend(1040, 1000).direction).toBe("flat");
+ expect(computeTrend(960, 1000).direction).toBe("flat");
+ // Just outside the band it counts.
+ expect(computeTrend(1051, 1000).direction).toBe("up");
+ expect(computeTrend(949, 1000).direction).toBe("down");
+ });
+
+ it("reports no percentage when there is no baseline", () => {
+ const fresh = computeTrend(500, 0);
+ expect(fresh.changePct).toBeNull();
+ expect(fresh.direction).toBe("up");
+ expect(formatTrend(fresh)).toBe("▲ new");
+ });
+
+ it("is flat, not up, when both windows are empty", () => {
+ const nothing = computeTrend(0, 0);
+ expect(nothing.direction).toBe("flat");
+ expect(formatTrend(nothing)).toBe("▬ no data");
+ });
+
+ it("flags low volume without lying about the direction", () => {
+ const noisy = computeTrend(6, 2);
+ expect(noisy.lowVolume).toBe(true);
+ expect(noisy.direction).toBe("up");
+
+ const solid = computeTrend(600, 200);
+ expect(solid.lowVolume).toBe(false);
+ });
+
+ it("formats magnitude, not sign, next to the arrow", () => {
+ expect(formatTrend(computeTrend(50, 100))).toBe("▼ 50%");
+ expect(formatTrend(computeTrend(1124, 1000))).toBe("▲ 12.4%");
+ expect(formatTrend(computeTrend(1020, 1000))).toBe("▬ 2%");
+ });
+});
+
+describe("portfolioVerdict", () => {
+ const directions = (up: number, flat: number, down: number): TrendDirection[] => [
+ ...Array(up).fill("up"),
+ ...Array(flat).fill("flat"),
+ ...Array(down).fill("down"),
+ ];
+
+ it("says growing when the aggregate is up, and shows the split", () => {
+ const verdict = portfolioVerdict(computeTrend(1500, 1000), directions(3, 1, 1));
+ expect(verdict).toContain("growing");
+ expect(verdict).toContain("3 up · 1 sideways · 1 down");
+ expect(verdict).toContain("5 properties");
+ });
+
+ it("says shrinking when the aggregate is down", () => {
+ expect(portfolioVerdict(computeTrend(500, 1000), directions(0, 0, 2))).toContain(
+ "shrinking",
+ );
+ });
+
+ it("says holding steady inside the flat band", () => {
+ expect(portfolioVerdict(computeTrend(1020, 1000), directions(1, 2, 1))).toContain(
+ "holding steady",
+ );
+ });
+
+ it("handles a single property and no properties at all", () => {
+ expect(portfolioVerdict(computeTrend(10, 5), directions(1, 0, 0))).toContain(
+ "1 property.",
+ );
+ expect(portfolioVerdict(computeTrend(0, 0), [])).toBe(
+ "No traffic recorded yet across your properties.",
+ );
+ });
+});
+
+describe("utcDayAxis", () => {
+ it("returns `days` UTC days ending today, oldest first", () => {
+ const axis = utcDayAxis(7, NOW);
+ expect(axis).toHaveLength(7);
+ expect(axis[0]).toBe("2026-08-03");
+ expect(axis[6]).toBe("2026-08-09");
+ });
+
+ it("spans month boundaries", () => {
+ expect(utcDayAxis(30, NOW)[0]).toBe("2026-07-11");
+ });
+});
+
+describe("buildDailyAxis", () => {
+ it("zero-fills days with no rows so gaps render flat", () => {
+ const daily = buildDailyAxis([row("2026-08-09", { events: 5, pageviews: 5 })], 3, NOW);
+ expect(daily.map((p) => p.date)).toEqual([
+ "2026-08-07",
+ "2026-08-08",
+ "2026-08-09",
+ ]);
+ expect(daily.map((p) => p.events)).toEqual([0, 0, 5]);
+ });
+
+ it("ignores rows outside the window instead of stretching the axis", () => {
+ const daily = buildDailyAxis(
+ [row("2026-01-01", { events: 999 }), row("2026-08-08", { events: 4 })],
+ 3,
+ NOW,
+ );
+ expect(daily).toHaveLength(3);
+ expect(daily.reduce((sum, p) => sum + p.events, 0)).toBe(4);
+ });
+
+ it("sums multiple rows landing on the same day", () => {
+ const daily = buildDailyAxis(
+ [
+ row("2026-08-09", { events: 3, ai: 1 }),
+ row("2026-08-09", { events: 7, ai: 2 }),
+ ],
+ 2,
+ NOW,
+ );
+ const today = daily[daily.length - 1];
+ expect(today.events).toBe(10);
+ expect(today.ai).toBe(3);
+ });
+
+ it("falls back to pageviews + interactions when bucket totals are missing", () => {
+ // Older rollups predate the bucket table, so `events` arrives as 0 on a day
+ // that plainly had traffic.
+ const daily = buildDailyAxis(
+ [row("2026-08-09", { pageviews: 8, interactions: 2, events: 0 })],
+ 1,
+ NOW,
+ );
+ expect(daily[0].events).toBe(10);
+ });
+});
+
+describe("toSeriesRow", () => {
+ it("coerces the bigint columns PostgREST hands back as strings", () => {
+ const coerced = toSeriesRow({
+ day: "2026-08-09",
+ pageviews: "12",
+ interactions: "3",
+ ai: "4",
+ bots: "5",
+ events: "20",
+ });
+ expect(coerced).toEqual({
+ day: "2026-08-09",
+ pageviews: 12,
+ interactions: 3,
+ ai: 4,
+ bots: 5,
+ events: 20,
+ });
+ });
+});