diff --git a/app/(app)/dashboard/analytics/page.tsx b/app/(app)/dashboard/analytics/page.tsx index efd59c0..2c9b610 100644 --- a/app/(app)/dashboard/analytics/page.tsx +++ b/app/(app)/dashboard/analytics/page.tsx @@ -465,19 +465,24 @@ export default async function PortfolioAnalyticsPage({ )} + {/* No projectId: this page aggregates across every project and + drives its own range control in the header, so the per-card + timeframe tabs stay hidden here. */} )} diff --git a/app/(app)/dashboard/projects/[id]/stats/page.tsx b/app/(app)/dashboard/projects/[id]/stats/page.tsx index 3265965..4c1346e 100644 --- a/app/(app)/dashboard/projects/[id]/stats/page.tsx +++ b/app/(app)/dashboard/projects/[id]/stats/page.tsx @@ -1,16 +1,15 @@ import { notFound } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { ProjectShell } from "@/components/project-shell"; -import { bucketLabel } from "@/lib/tracker/categorize"; -import { countryNameFromCode } from "@/lib/tracker/country"; import { env } from "@/lib/env"; import { DEFAULT_PROJECT_ENGINES, type Engine } from "@/lib/credits"; import type { ProjectStatus } from "@/app/actions/projects"; import { TrackerAnalytics, - type TrackerListItem, + type TrackerPanels, } from "@/components/charts/tracker-analytics"; -import { buildDailyAxis, toSeriesRow } from "@/lib/tracker/series"; +import { fetchPanels, PANEL_KEYS } from "@/lib/tracker/panels"; +import { DEFAULT_TRACKER_RANGE, trackerRange } from "@/lib/tracker/ranges"; import { InstallSnippet } from "./install-snippet"; import { TrackerToggle } from "./tracker-toggle"; import { CareersToggle } from "./careers-toggle"; @@ -20,9 +19,6 @@ import { StatsSubnav } from "./stats-subnav"; 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; toSeriesRow() coerces them. -type SeriesRow = Parameters[0]; type BoundRepo = { id: string; full_name: string; @@ -30,14 +26,6 @@ type BoundRepo = { default_branch: string | null; added_at: string; }; -type DeviceRow = { - device_type: string; - browser: string; - os: string; - count: number; -}; - -const WINDOW_DAYS = 30; export default async function ProjectStatsPage({ params, @@ -59,141 +47,36 @@ export default async function ProjectStatsPage({ // ordering silently dropped older history, so charts looked like tracking // "just started". Aggregating in Postgres returns at most (days) or (lim) // rows per call, so history is always complete. - const days = WINDOW_DAYS; - const [ - seriesRes, - bucketsRes, - mixRes, - pagesRes, - referrersRes, - actionsRes, - exitRes, - countriesRes, - citiesRes, - devicesRes, - ] = await Promise.all([ - supabase.rpc("tracker_daily_series", { p_project: id, days }), - supabase.rpc("tracker_bucket_totals", { p_project: id, days, lim: 10 }), - supabase.rpc("tracker_event_mix", { p_project: id, days }), - supabase.rpc("tracker_top_pages", { p_project: id, days, lim: 10 }), - supabase.rpc("tracker_top_referrers", { p_project: id, days, lim: 10 }), - supabase.rpc("tracker_top_actions", { p_project: id, days, lim: 10 }), - supabase.rpc("tracker_top_exit_pages", { p_project: id, days, lim: 10 }), - supabase.rpc("tracker_top_countries", { p_project: id, days, lim: 10 }), - supabase.rpc("tracker_top_cities", { p_project: id, days, lim: 10 }), - supabase.rpc("tracker_device_totals", { p_project: id, days }), - ]); - - const series = ((seriesRes.data ?? []) as SeriesRow[]).map(toSeriesRow); - const daily = buildDailyAxis(series, WINDOW_DAYS); + // + // This renders every panel at the default range. Each card then owns its own + // timeframe tabs and re-fetches just itself from + // /api/projects/:id/tracker-stats, so narrowing one chart to the last hour + // does not re-run the other eleven. + const range = trackerRange(DEFAULT_TRACKER_RANGE); + const panels = (await fetchPanels( + supabase, + id, + PANEL_KEYS, + range, + )) as unknown as TrackerPanels; // 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" // is everything that isn't an AI referral or a bot (human/search/social/ // referral), matching the original bucket-prefix split. - const totalAi = series.reduce((s, p) => s + p.ai, 0); - const totalBot = series.reduce((s, p) => s + p.bots, 0); - const grandTotal = series.reduce((s, p) => s + p.events, 0); - const eventTotal = series.reduce((s, p) => s + p.pageviews + p.interactions, 0); + const points = panels.series.points; + const totalAi = points.reduce((s, p) => s + p.ai, 0); + const totalBot = points.reduce((s, p) => s + p.bots, 0); + const grandTotal = points.reduce((s, p) => s + p.events, 0); + const eventTotal = points.reduce((s, p) => s + p.pageviews + p.interactions, 0); const totalHuman = Math.max(0, grandTotal - totalAi - totalBot); - 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 - : grandTotal - ? [{ label: "Pageview", value: grandTotal }] - : []; - - const topPages = ( - (pagesRes.data ?? []) as Array<{ page_path: string; total: number | string }> - ).map((r) => ({ label: r.page_path || "/", 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 topActions = ( - (actionsRes.data ?? []) as Array<{ - event: string; - event_target: string; - total: number | string; - }> - ).map((r) => ({ - label: `${eventLabel(r.event)} · ${r.event_target}`, - value: Number(r.total), - })); - - const exitPages = ( - (exitRes.data ?? []) as Array<{ page_path: string; total: number | string }> - ).map((r) => ({ label: r.page_path || "/", 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); + // Older projects have rollup rows in tracker_daily_stats but nothing in + // tracker_event_daily_stats, which would leave Event mix empty on a page + // that is plainly showing traffic. Fall back to a single Pageview row. + if (!panels.events.length && grandTotal) { + panels.events = [{ label: "Pageview", value: grandTotal }]; + } const trackerEnabled = !!(project as { tracker_enabled?: boolean }) .tracker_enabled; @@ -362,18 +245,9 @@ export default async function ProjectStatsPage({ ) : ( )} @@ -429,44 +303,6 @@ function ConnectedRepos({ ); } -function topDeviceItems( - rows: DeviceRow[], - labelFor: (row: DeviceRow) => 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(" "); -} - function Metric({ label, value, diff --git a/app/api/projects/[id]/tracker-stats/route.ts b/app/api/projects/[id]/tracker-stats/route.ts new file mode 100644 index 0000000..ae226fd --- /dev/null +++ b/app/api/projects/[id]/tracker-stats/route.ts @@ -0,0 +1,72 @@ +// GET /api/projects/[id]/tracker-stats?range=1h&panel=pages +// +// Re-renders one stats panel at a different timeframe. The stats page +// server-renders every panel at the default range; each card's timeframe tabs +// call this for the range the reader picks, so switching a tab costs one small +// aggregate instead of a full page reload. +// +// `panel` may be repeated (or comma-separated) to fetch several at once. +// Requires project owner or member auth, same as the other project routes. + +import { NextRequest, NextResponse } from "next/server"; +import { requireProjectAccess } from "@/lib/lx/currentSite"; +import { serviceClient } from "@/lib/supabase/service"; +import { trackerRange, rangesForPanel } from "@/lib/tracker/ranges"; +import { fetchPanels, PANEL_KEYS, type PanelKey } from "@/lib/tracker/panels"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: projectId } = await params; + + const access = await requireProjectAccess(projectId, { allowViewer: true }); + if (!access.ok) { + const status = access.error === "Not authenticated." ? 401 : 404; + return NextResponse.json({ error: access.error }, { status }); + } + + const sp = request.nextUrl.searchParams; + const range = trackerRange(sp.get("range")); + + const requested = sp + .getAll("panel") + .flatMap((v) => v.split(",")) + .map((v) => v.trim()) + .filter(Boolean); + const panels = (requested.length ? requested : PANEL_KEYS).filter( + (p): p is PanelKey => (PANEL_KEYS as string[]).includes(p), + ); + + if (!panels.length) { + return NextResponse.json({ error: "Unknown panel." }, { status: 400 }); + } + + // A panel that has no data at this resolution should say so rather than + // quietly answering a different question — devices/browsers/OS and exit + // pages have no sub-day source, so a 1H request for them is a client bug. + const unsupported = panels.filter( + (p) => !rangesForPanel(p).some((r) => r.key === range.key), + ); + if (unsupported.length) { + return NextResponse.json( + { + error: `Panel(s) ${unsupported.join(", ")} do not support the ${range.key} range.`, + }, + { status: 400 }, + ); + } + + try { + const data = await fetchPanels(serviceClient(), projectId, panels, range); + return NextResponse.json({ range: range.key, panels: data }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Stats query failed." }, + { status: 500 }, + ); + } +} diff --git a/components/charts/timeframe-tabs.tsx b/components/charts/timeframe-tabs.tsx new file mode 100644 index 0000000..5f893a6 --- /dev/null +++ b/components/charts/timeframe-tabs.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useRef } from "react"; +import type { TrackerRange, TrackerRangeKey } from "@/lib/tracker/ranges"; + +// The tab strip that sits in each stats card header. Presentational — the +// owning panel holds the selected range and does the fetching. +export function TimeframeTabs({ + ranges, + value, + onChange, + disabled = false, + label = "Timeframe", +}: { + ranges: TrackerRange[]; + value: TrackerRangeKey; + onChange: (key: TrackerRangeKey) => void; + disabled?: boolean; + label?: string; +}) { + const refs = useRef>([]); + + // Roving arrow-key focus: a tablist that only responds to clicks strands + // keyboard users on whichever tab happens to be selected. + function onKeyDown(event: React.KeyboardEvent, index: number) { + const delta = + event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (!delta) return; + event.preventDefault(); + const next = (index + delta + ranges.length) % ranges.length; + refs.current[next]?.focus(); + onChange(ranges[next].key); + } + + return ( +
+ {ranges.map((range, index) => { + const selected = range.key === value; + return ( + + ); + })} +
+ ); +} diff --git a/components/charts/tracker-analytics.tsx b/components/charts/tracker-analytics.tsx index 07986e3..d204b4b 100644 --- a/components/charts/tracker-analytics.tsx +++ b/components/charts/tracker-analytics.tsx @@ -15,6 +15,15 @@ import { XAxis, YAxis, } from "recharts"; +import { TimeframeTabs } from "./timeframe-tabs"; +import { usePanelRange } from "./use-panel-range"; +import { + DEFAULT_TRACKER_RANGE, + trackerRange, + type TrackerRange, + type TrackerRangeKey, +} from "@/lib/tracker/ranges"; +import type { ListItem, SeriesPayload } from "@/lib/tracker/panels"; export type TrackerDailyPoint = { date: string; @@ -25,9 +34,23 @@ export type TrackerDailyPoint = { bots: number; }; -export type TrackerListItem = { - label: string; - value: number; +export type TrackerListItem = ListItem; + +// Every panel the page server-renders, keyed the same way the API route keys +// its response so a tab switch can swap one in place. +export type TrackerPanels = { + series: SeriesPayload; + events: ListItem[]; + sources: ListItem[]; + pages: ListItem[]; + exitPages: ListItem[]; + referrers: ListItem[]; + actions: ListItem[]; + countries: ListItem[]; + cities: ListItem[]; + devices: ListItem[]; + browsers: ListItem[]; + operatingSystems: ListItem[]; }; const COLORS = [ @@ -40,252 +63,399 @@ const COLORS = [ ]; export function TrackerAnalytics({ - daily, - events, - sources, - pages, - exitPages, - referrers, - actions, - countries, - cities, - devices, - browsers, - operatingSystems, + projectId, + initial, + initialRange = DEFAULT_TRACKER_RANGE, }: { - daily: TrackerDailyPoint[]; - events: TrackerListItem[]; - sources: TrackerListItem[]; - pages: TrackerListItem[]; - exitPages: TrackerListItem[]; - referrers: TrackerListItem[]; - actions: TrackerListItem[]; - countries: TrackerListItem[]; - cities: TrackerListItem[]; - devices: TrackerListItem[]; - browsers: TrackerListItem[]; - operatingSystems: TrackerListItem[]; + /** Omitted by the portfolio page, which has no single-project endpoint. */ + projectId?: string; + initial: TrackerPanels; + initialRange?: TrackerRangeKey; }) { - const total = daily.reduce((sum, point) => sum + point.events, 0); + const common = { projectId, initialRange }; return (
-
-
-
-

Traffic pulse

-

- Pageviews, interactions, AI referrals, and bot crawls. -

-
- - {total.toLocaleString()} events - -
-
- - - - - - - - - - - - - -
-
+
- - + +
- - +
- -
- -
- - +
-
); } -function Breakdown({ title, data }: { title: string; data: TrackerListItem[] }) { - const total = data.reduce((sum, row) => sum + row.value, 0); +// Shared card chrome: title, the timeframe tabs, a right-hand total, and the +// loading / error treatment. The body stays mounted and dims while a range +// loads — swapping it for a spinner makes every tab click flash the card and +// loses the shape the reader is comparing against. +function PanelFrame({ + title, + subtitle, + total, + ranges, + range, + onRange, + showTabs, + loading, + error, + children, + className = "card p-4", +}: { + title: string; + subtitle?: string; + total?: number; + ranges: TrackerRange[]; + range: TrackerRangeKey; + onRange: (key: TrackerRangeKey) => void; + showTabs: boolean; + loading: boolean; + error: string | null; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ + {error + ? "—" + : total === undefined + ? "" + : `${total.toLocaleString()} ${total === 1 ? "event" : "events"}`} + + {showTabs && ( + + )} +
+
+ {error ? ( +

+ Could not load this timeframe: {error} +

+ ) : ( +
+ {children} +
+ )} +
+ ); +} - if (!total) { - return ( -
- No data yet. -
+function TrafficPulse({ + projectId, + initialData, + initialRange, +}: { + projectId?: string; + initialData: SeriesPayload; + initialRange: TrackerRangeKey; +}) { + const { ranges, range, setRange, data, loading, error, showTabs } = + usePanelRange( + projectId, + "series", + initialData, + initialRange, ); - } + + const points = data?.points ?? []; + const total = points.reduce((sum, point) => sum + point.events, 0); + const byTime = data?.granularity === "time"; return ( -
-
-

{title}

- - {total.toLocaleString()} total - -
-
-
- +
+ + - - - {data.slice(0, 6).map((entry, index) => ( - - ))} - - - - -
- + + + + + + + + + + +
-
+ + ); +} + +function BreakdownPanel({ + projectId, + panel, + title, + initialData, + initialRange, +}: { + projectId?: string; + panel: string; + title: string; + initialData: ListItem[]; + initialRange: TrackerRangeKey; +}) { + const { ranges, range, setRange, data, loading, error, showTabs } = + usePanelRange(projectId, panel, initialData, initialRange); + + const rows = data ?? []; + const total = rows.reduce((sum, row) => sum + row.value, 0); + + return ( + + {total ? ( +
+
+ + + + {rows.slice(0, 6).map((entry, index) => ( + + ))} + + + + +
+ +
+ ) : ( + + )} +
); } -function RankedList({ +function RankedPanel({ + projectId, + panel, title, - data, empty, + initialData, + initialRange, }: { + projectId?: string; + panel: string; title: string; - data: TrackerListItem[]; empty: string; + initialData: ListItem[]; + initialRange: TrackerRangeKey; }) { - const total = data.reduce((sum, row) => sum + row.value, 0); + const { ranges, range, setRange, data, loading, error, showTabs } = + usePanelRange(projectId, panel, initialData, initialRange); + + const rows = data ?? []; + const total = rows.reduce((sum, row) => sum + row.value, 0); return ( -
-
-

{title}

- - {total.toLocaleString()} total - -
+ {total ? ( <>
@@ -297,7 +467,7 @@ function RankedList({ initialDimension={{ width: 420, height: 176 }} > @@ -306,7 +476,11 @@ function RankedList({ strokeDasharray="3 3" horizontal={false} /> - + - +
- + ) : ( -

{empty}

+ )} -
+ + ); +} + +// An empty panel at 1H usually means "quiet hour", not "never tracked" — say +// which, so a reader narrowing the window does not read it as data loss. +function EmptyRange({ + message, + range, +}: { + message: string; + range: TrackerRangeKey; +}) { + return ( +

+ {message}{" "} + + ({trackerRange(range).description.toLowerCase()}) + +

); } -function RankRows({ data, total }: { data: TrackerListItem[]; total: number }) { +function RankRows({ data, total }: { data: ListItem[]; total: number }) { return (
{data.slice(0, 8).map((row, index) => { const share = total ? (row.value / total) * 100 : 0; return ( -
+
{row.label}
@@ -383,6 +584,22 @@ function formatLongDate(value: unknown) { }); } +function formatTime(value: string) { + return new Date(value).toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); +} + +function formatLongTime(value: unknown) { + return new Date(String(value)).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + function shortLabel(value: string) { return value.length > 18 ? `${value.slice(0, 17)}...` : value; } diff --git a/components/charts/use-panel-range.ts b/components/charts/use-panel-range.ts new file mode 100644 index 0000000..54da23d --- /dev/null +++ b/components/charts/use-panel-range.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + DEFAULT_TRACKER_RANGE, + rangesForPanel, + type TrackerRangeKey, +} from "@/lib/tracker/ranges"; +import type { PanelPayload } from "@/lib/tracker/panels"; + +// Range state + fetching for one stats card. +// +// The panel starts on whatever the page server-rendered, so the default range +// costs no request at all. Every other range is fetched once and cached for +// the life of the card — flipping back and forth between 1H and 1M is a +// common way to read these charts, and re-querying on each flip makes the +// comparison feel slower than the data it is showing. +// `projectId` is undefined on the portfolio analytics page, which aggregates +// across every project and drives its own page-wide range control — there is +// no single-project endpoint to ask, so those panels stay on what the server +// rendered and show no tabs. +export function usePanelRange( + projectId: string | undefined, + panel: string, + initialData: T, + initialRange: TrackerRangeKey = DEFAULT_TRACKER_RANGE, +) { + const ranges = rangesForPanel(panel); + const [range, setRange] = useState(initialRange); + const [data, setData] = useState(initialData); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const cache = useRef(new Map([[initialRange, initialData]])); + // Guards against a slow early request landing after a later one and + // repainting the card with the range the reader already moved off. + const latest = useRef(0); + + useEffect(() => { + if (!projectId) return; + + const cached = cache.current.get(range); + if (cached) { + setData(cached); + setError(null); + setLoading(false); + return; + } + + const seq = ++latest.current; + const controller = new AbortController(); + setLoading(true); + setError(null); + + (async () => { + try { + const res = await fetch( + `/api/projects/${projectId}/tracker-stats?range=${range}&panel=${panel}`, + { signal: controller.signal }, + ); + const body = await res.json(); + if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`); + const payload = body?.panels?.[panel] as T | undefined; + if (payload === undefined) throw new Error("Panel missing from response."); + cache.current.set(range, payload); + if (seq === latest.current) { + setData(payload); + setLoading(false); + } + } catch (err) { + if (controller.signal.aborted) return; + if (seq !== latest.current) return; + setError(err instanceof Error ? err.message : "Could not load range."); + setLoading(false); + } + })(); + + return () => controller.abort(); + }, [projectId, panel, range]); + + const changeRange = useCallback((key: TrackerRangeKey) => setRange(key), []); + + return { + ranges, + range, + setRange: changeRange, + data, + loading, + error, + // Tabs are meaningless without an endpoint to switch against. + showTabs: !!projectId, + }; +} diff --git a/lib/tracker/panels.ts b/lib/tracker/panels.ts new file mode 100644 index 0000000..94239c5 --- /dev/null +++ b/lib/tracker/panels.ts @@ -0,0 +1,438 @@ +// One place that turns (project, range, panel) into the shape a stats card +// renders. +// +// WHY here and not in the page: the stats page server-renders every panel at +// the default range, and /api/projects/:id/tracker-stats re-renders one panel +// when a timeframe tab is clicked. Both have to agree exactly — label +// formatting, top-N truncation, the zero-filled axis — or a tab click would +// silently reshape a chart that had not changed data. So the mapping lives +// once, and both callers pass their own Supabase client (the page's server +// client and the route handler's, both RLS-scoped to the signed-in user). +// +// Each panel picks its RPC from the range: raw tracker_events twins under a +// day, *_daily_stats rollups above it. See lib/tracker/ranges.ts. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { bucketLabel } from "@/lib/tracker/categorize"; +import { countryNameFromCode } from "@/lib/tracker/country"; +import { buildDailyAxis, toSeriesRow } from "@/lib/tracker/series"; +import { + isRawRange, + type TrackerRange, +} from "@/lib/tracker/ranges"; + +export type PanelKey = + | "series" + | "events" + | "sources" + | "pages" + | "exitPages" + | "referrers" + | "actions" + | "countries" + | "cities" + | "devices" + | "browsers" + | "operatingSystems"; + +export const PANEL_KEYS: PanelKey[] = [ + "series", + "events", + "sources", + "pages", + "exitPages", + "referrers", + "actions", + "countries", + "cities", + "devices", + "browsers", + "operatingSystems", +]; + +export type ListItem = { label: string; value: number }; + +export type SeriesPoint = { + date: string; + events: number; + pageviews: number; + interactions: number; + ai: number; + bots: number; +}; + +export type SeriesPayload = { + points: SeriesPoint[]; + /** "day" axis ticks are dates; "time" ticks are clock times. */ + granularity: "day" | "time"; +}; + +export type PanelPayload = SeriesPayload | ListItem[]; + +type Sb = SupabaseClient; + +const TOP_N = 10; + +// `days: 0` means all history; resolve it against the project's first rollup +// day so the axis is sized to real data instead of an arbitrary epoch. Capped +// at 10 years so one bad row cannot ask the chart for 100k points. +const MAX_ALL_DAYS = 3650; + +export async function resolveDays( + sb: Sb, + projectId: string, + range: TrackerRange, +): Promise { + if (range.days && range.days > 0) return range.days; + const { data } = await sb.rpc("tracker_first_day", { p_project: projectId }); + const first = typeof data === "string" ? data : null; + if (!first) return 30; + const start = Date.parse(`${first}T00:00:00Z`); + if (!Number.isFinite(start)) return 30; + const spanDays = Math.floor((Date.now() - start) / 86_400_000) + 1; + return Math.min(Math.max(spanDays, 1), MAX_ALL_DAYS); +} + +/** Fetch one panel. Errors surface as an empty panel rather than a broken page. */ +export async function fetchPanel( + sb: Sb, + projectId: string, + panel: PanelKey, + range: TrackerRange, + days: number, +): Promise { + const raw = isRawRange(range); + const minutes = range.minutes ?? 1440; + + switch (panel) { + case "series": { + if (raw) { + const { data } = await sb.rpc("tracker_recent_series", { + p_project: projectId, + p_minutes: minutes, + p_bucket_seconds: range.bucketSeconds ?? 300, + }); + return { + points: buildBucketAxis( + (data ?? []) as RecentSeriesRow[], + minutes, + range.bucketSeconds ?? 300, + ), + granularity: "time", + }; + } + const { data } = await sb.rpc("tracker_daily_series", { + p_project: projectId, + days, + }); + const series = ((data ?? []) as Parameters[0][]).map( + toSeriesRow, + ); + return { points: buildDailyAxis(series, days), granularity: "day" }; + } + + case "sources": { + const { data } = raw + ? await sb.rpc("tracker_recent_bucket_totals", { + p_project: projectId, + p_minutes: minutes, + lim: TOP_N, + }) + : await sb.rpc("tracker_bucket_totals", { + p_project: projectId, + days, + lim: TOP_N, + }); + return ((data ?? []) as Array<{ bucket: string; total: number | string }>).map( + (r) => ({ label: bucketLabel(r.bucket), value: Number(r.total) }), + ); + } + + case "events": { + const { data } = raw + ? await sb.rpc("tracker_recent_event_mix", { + p_project: projectId, + p_minutes: minutes, + }) + : await sb.rpc("tracker_event_mix", { p_project: projectId, days }); + return ((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, TOP_N); + } + + case "pages": { + const { data } = raw + ? await sb.rpc("tracker_recent_top_pages", { + p_project: projectId, + p_minutes: minutes, + lim: TOP_N, + }) + : await sb.rpc("tracker_top_pages", { + p_project: projectId, + days, + lim: TOP_N, + }); + return ( + (data ?? []) as Array<{ page_path: string; total: number | string }> + ).map((r) => ({ label: r.page_path || "/", value: Number(r.total) })); + } + + case "exitPages": { + // Rollup-only: tracker_exit_sessions keys on a date, not a timestamp. + const { data } = await sb.rpc("tracker_top_exit_pages", { + p_project: projectId, + days, + lim: TOP_N, + }); + return ( + (data ?? []) as Array<{ page_path: string; total: number | string }> + ).map((r) => ({ label: r.page_path || "/", value: Number(r.total) })); + } + + case "referrers": { + const { data } = raw + ? await sb.rpc("tracker_recent_top_referrers", { + p_project: projectId, + p_minutes: minutes, + lim: TOP_N, + }) + : await sb.rpc("tracker_top_referrers", { + p_project: projectId, + days, + lim: TOP_N, + }); + return ( + (data ?? []) as Array<{ referrer_host: string; total: number | string }> + ).map((r) => ({ label: r.referrer_host, value: Number(r.total) })); + } + + case "actions": { + const { data } = raw + ? await sb.rpc("tracker_recent_top_actions", { + p_project: projectId, + p_minutes: minutes, + lim: TOP_N, + }) + : await sb.rpc("tracker_top_actions", { + p_project: projectId, + days, + lim: TOP_N, + }); + return ( + (data ?? []) as Array<{ + event: string; + event_target: string; + total: number | string; + }> + ).map((r) => ({ + label: `${eventLabel(r.event)} · ${r.event_target}`, + value: Number(r.total), + })); + } + + case "countries": { + const { data } = raw + ? await sb.rpc("tracker_recent_top_countries", { + p_project: projectId, + p_minutes: minutes, + lim: TOP_N, + }) + : await sb.rpc("tracker_top_countries", { + p_project: projectId, + days, + lim: TOP_N, + }); + return ( + (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); + } + + case "cities": { + const { data } = raw + ? await sb.rpc("tracker_recent_top_cities", { + p_project: projectId, + p_minutes: minutes, + lim: TOP_N, + }) + : await sb.rpc("tracker_top_cities", { + p_project: projectId, + days, + lim: TOP_N, + }); + return ( + (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); + } + + case "devices": + case "browsers": + case "operatingSystems": { + // Rollup-only: the raw event row keeps a user_agent string but no parsed + // device_type / browser / os columns. + const { data } = await sb.rpc("tracker_device_totals", { + p_project: projectId, + days, + }); + const rows = ( + (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), + })); + if (panel === "devices") { + return topDeviceItems(rows, (row) => deviceTypeLabel(row.device_type)); + } + if (panel === "browsers") return topDeviceItems(rows, (row) => row.browser); + return topDeviceItems(rows, (row) => row.os); + } + } +} + +/** Fetch several panels for one range, in parallel. */ +export async function fetchPanels( + sb: Sb, + projectId: string, + panels: PanelKey[], + range: TrackerRange, +): Promise> { + const days = await resolveDays(sb, projectId, range); + const results = await Promise.all( + panels.map((p) => fetchPanel(sb, projectId, p, range, days)), + ); + return Object.fromEntries(panels.map((p, i) => [p, results[i]])); +} + +type RecentSeriesRow = { + ts: string; + pageviews: number | string; + interactions: number | string; + ai: number | string; + bots: number | string; + events: number | string; +}; + +// Zero-fill the sub-day series across every bucket in the window. The RPC only +// returns buckets that saw traffic, and a line that skips its quiet buckets +// reads as a smooth decline rather than the gap it actually is — the exact +// misreading these tabs exist to prevent. +export function buildBucketAxis( + rows: RecentSeriesRow[], + minutes: number, + bucketSeconds: number, + now = new Date(), +): SeriesPoint[] { + const step = Math.max(60, bucketSeconds) * 1000; + const end = Math.floor(now.getTime() / step) * step; + // The window `now - minutes .. now` starts inside its oldest bucket, so the + // number of bucket *starts* it covers is one more than it divides into — + // 11:00 through 12:00 is 13 five-minute buckets, not 12. Sizing this by + // division alone dropped the oldest bucket on every sub-day tab, which the + // RPC had happily returned. The final bucket is the in-progress one. + const count = Math.max(1, Math.floor((minutes * 60_000) / step) + 1); + const start = end - (count - 1) * step; + + const byTs = new Map(); + for (let t = start; t <= end; t += step) { + byTs.set(t, { + date: new Date(t).toISOString(), + events: 0, + pageviews: 0, + interactions: 0, + ai: 0, + bots: 0, + }); + } + + for (const row of rows) { + const t = Date.parse(row.ts); + if (!Number.isFinite(t)) continue; + const point = byTs.get(Math.floor(t / step) * step); + if (!point) continue; + point.pageviews += Number(row.pageviews); + point.interactions += Number(row.interactions); + point.ai += Number(row.ai); + point.bots += Number(row.bots); + point.events += Number(row.events); + } + + return Array.from(byTs.values()); +} + +export 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, +): ListItem[] { + 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, TOP_N); +} + +export function deviceTypeLabel(deviceType: string) { + switch (deviceType) { + case "mobile": + return "Mobile"; + case "tablet": + return "Tablet"; + case "desktop": + return "Desktop"; + case "bot": + return "Bot"; + default: + return ""; + } +} + +export function eventLabel(event: string) { + return event + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} diff --git a/lib/tracker/ranges.ts b/lib/tracker/ranges.ts new file mode 100644 index 0000000..4e6c226 --- /dev/null +++ b/lib/tracker/ranges.ts @@ -0,0 +1,98 @@ +// Timeframe tabs for the project stats graphs. +// +// Two data sources sit behind these keys. Windows of a day or less are served +// from public.tracker_events, the raw row-per-event table — which /api/track +// prunes at 24h, so nothing shorter than a day can come from anywhere else and +// nothing longer can come from here. Windows above a day are served from the +// *_daily_stats rollups, whose finest resolution is one UTC calendar day. +// +// `minutes` is set on the raw ranges, `days` on the rollup ranges; which field +// is present is what the API route switches on, so they are deliberately +// mutually exclusive rather than one being derived from the other. + +export type TrackerRangeKey = "1h" | "4h" | "1d" | "1w" | "1m" | "1y" | "all"; + +export type TrackerRange = { + key: TrackerRangeKey; + /** Tab label. */ + label: string; + /** Long form, for the panel subtitle and the tab's title attribute. */ + description: string; + /** Set on raw-event ranges (<= 24h). */ + minutes?: number; + /** Set on rollup ranges (> 24h). `days: 0` means "all history". */ + days?: number; + /** Series bucket width, raw ranges only. */ + bucketSeconds?: number; +}; + +export const TRACKER_RANGES: TrackerRange[] = [ + { + key: "1h", + label: "1H", + description: "Last hour, 5-minute buckets", + minutes: 60, + bucketSeconds: 300, + }, + { + key: "4h", + label: "4H", + description: "Last 4 hours, 15-minute buckets", + minutes: 240, + bucketSeconds: 900, + }, + { + key: "1d", + label: "1D", + description: "Last 24 hours, hourly buckets", + minutes: 1440, + bucketSeconds: 3600, + }, + { key: "1w", label: "1W", description: "Last 7 days, daily", days: 7 }, + { key: "1m", label: "1M", description: "Last 30 days, daily", days: 30 }, + { key: "1y", label: "1Y", description: "Last 365 days, daily", days: 365 }, + { key: "all", label: "All", description: "All history, daily", days: 0 }, +]; + +export const DEFAULT_TRACKER_RANGE: TrackerRangeKey = "1m"; + +const BY_KEY = new Map(TRACKER_RANGES.map((r) => [r.key, r])); + +export function trackerRange(key: string | null | undefined): TrackerRange { + return ( + BY_KEY.get((key ?? "") as TrackerRangeKey) ?? + BY_KEY.get(DEFAULT_TRACKER_RANGE)! + ); +} + +/** True when the range is served from tracker_events rather than the rollups. */ +export function isRawRange(range: TrackerRange): boolean { + return typeof range.minutes === "number"; +} + +// Device type / browser / OS live only in tracker_device_daily_stats — the raw +// event row carries a user_agent string but no parsed columns — so those three +// panels cannot offer a sub-day window. They get the rollup ranges only, with +// "1D" meaning today's UTC rollup rather than a rolling 24h. Exit pages are +// tracked per session against a `last_day` date, not a timestamp, so they are +// rollup-only for the same reason. +export const ROLLUP_ONLY_RANGES: TrackerRangeKey[] = [ + "1d", + "1w", + "1m", + "1y", + "all", +]; + +export const PANEL_RANGE_KEYS: Record = { + devices: ROLLUP_ONLY_RANGES, + browsers: ROLLUP_ONLY_RANGES, + operatingSystems: ROLLUP_ONLY_RANGES, + exitPages: ROLLUP_ONLY_RANGES, +}; + +export function rangesForPanel(panel: string): TrackerRange[] { + const allowed = PANEL_RANGE_KEYS[panel]; + if (!allowed) return TRACKER_RANGES; + return TRACKER_RANGES.filter((r) => allowed.includes(r.key)); +} diff --git a/supabase/migrations/20260830120000_tracker_recent_rpcs.sql b/supabase/migrations/20260830120000_tracker_recent_rpcs.sql new file mode 100644 index 0000000..3d5e0db --- /dev/null +++ b/supabase/migrations/20260830120000_tracker_recent_rpcs.sql @@ -0,0 +1,260 @@ +-- Sub-day aggregates for the per-graph timeframe tabs on /projects/:id/stats. +-- +-- WHY: every existing tracker_* RPC reads a *_daily_stats rollup, so the +-- finest window they can express is one UTC calendar day. The stats page needs +-- 1h / 4h / 24h tabs, and the only place that resolution exists is +-- public.tracker_events, which /api/track writes a row-per-event into and +-- prunes at 24h. These functions are the raw-table twins of the daily RPCs: +-- same result shapes, same top-N truncation, windowed by minutes instead of +-- days so the API route can swap one for the other by range key alone. +-- +-- Anything older than 24h is not in tracker_events at all, so the 1w / 1m / +-- 1y / all tabs stay on the daily RPCs. p_minutes is clamped to 1440 here to +-- make that boundary explicit rather than silently returning a short window. +-- +-- security invoker, matching 20260724120000_tracker_stats_rpc.sql: RLS on +-- tracker_events already scopes SELECT to project members and the owner, so +-- the caller inherits exactly that access. +-- +-- Apply one file at a time via the Supabase MCP, not `db push` — prod +-- migration history has diverged from this directory. + +-- Per-bucket series for the 4 Traffic pulse lines. p_bucket_seconds sets the +-- resolution (300 = 5min for the 1h tab, 900 for 4h, 3600 for 24h); floor-to- +-- epoch keeps buckets aligned to the wall clock rather than to `now()`. +create or replace function public.tracker_recent_series( + p_project uuid, + p_minutes integer default 60, + p_bucket_seconds integer default 300 +) +returns table ( + ts timestamptz, + pageviews bigint, + interactions bigint, + ai bigint, + bots bigint, + events bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + with args as ( + select least(greatest(coalesce(p_minutes, 60), 1), 1440) as mins, + least(greatest(coalesce(p_bucket_seconds, 300), 60), 86400) as secs + ) + select to_timestamp( + floor(extract(epoch from e.occurred_at) / (select secs from args)) + * (select secs from args) + ) as ts, + count(*) filter (where e.event = 'pageview')::bigint as pageviews, + count(*) filter (where e.event <> 'pageview')::bigint as interactions, + count(*) filter (where e.bucket like 'ai_referral:%')::bigint as ai, + count(*) filter (where e.bucket like 'bot:%')::bigint as bots, + count(*)::bigint as events + from public.tracker_events e + where e.project_id = p_project + and e.occurred_at >= now() - ((select mins from args) || ' minutes')::interval + group by 1 + order by 1; +$$; + +-- Top source buckets (Top sources breakdown). +create or replace function public.tracker_recent_bucket_totals( + p_project uuid, + p_minutes integer default 60, + lim integer default 10 +) +returns table (bucket text, total bigint) +language sql +stable +security invoker +set search_path = public +as $$ + select bucket, count(*)::bigint as total + from public.tracker_events + where project_id = p_project + and coalesce(bucket, '') <> '' + and occurred_at >= now() + - (least(greatest(coalesce(p_minutes, 60), 1), 1440) || ' minutes')::interval + group by bucket + order by total desc + limit greatest(coalesce(lim, 10), 1); +$$; + +-- Event mix. +create or replace function public.tracker_recent_event_mix( + p_project uuid, + p_minutes integer default 60 +) +returns table (event text, total bigint) +language sql +stable +security invoker +set search_path = public +as $$ + select event, count(*)::bigint as total + from public.tracker_events + where project_id = p_project + and occurred_at >= now() + - (least(greatest(coalesce(p_minutes, 60), 1), 1440) || ' minutes')::interval + group by event + order by total desc; +$$; + +-- Top pageview paths. This is the one that answers "did /login calm down after +-- we throttled it" at the resolution where the answer is still moving. +create or replace function public.tracker_recent_top_pages( + p_project uuid, + p_minutes integer default 60, + lim integer default 10 +) +returns table (page_path text, total bigint) +language sql +stable +security invoker +set search_path = public +as $$ + select coalesce(nullif(page_path, ''), '/') as page_path, count(*)::bigint as total + from public.tracker_events + where project_id = p_project + and event = 'pageview' + and occurred_at >= now() + - (least(greatest(coalesce(p_minutes, 60), 1), 1440) || ' minutes')::interval + group by 1 + order by total desc + limit greatest(coalesce(lim, 10), 1); +$$; + +-- Top external referrer hosts. +create or replace function public.tracker_recent_top_referrers( + p_project uuid, + p_minutes integer default 60, + lim integer default 10 +) +returns table (referrer_host text, total bigint) +language sql +stable +security invoker +set search_path = public +as $$ + select referrer_host, count(*)::bigint as total + from public.tracker_events + where project_id = p_project + and coalesce(referrer_host, '') <> '' + and occurred_at >= now() + - (least(greatest(coalesce(p_minutes, 60), 1), 1440) || ' minutes')::interval + group by referrer_host + order by total desc + limit greatest(coalesce(lim, 10), 1); +$$; + +-- Top interactions (non-pageview events carrying a target label). +create or replace function public.tracker_recent_top_actions( + p_project uuid, + p_minutes integer default 60, + lim integer default 10 +) +returns table (event text, event_target text, total bigint) +language sql +stable +security invoker +set search_path = public +as $$ + select event, event_target, count(*)::bigint as total + from public.tracker_events + where project_id = p_project + and event <> 'pageview' + and coalesce(event_target, '') <> '' + and occurred_at >= now() + - (least(greatest(coalesce(p_minutes, 60), 1), 1440) || ' minutes')::interval + group by event, event_target + order by total desc + limit greatest(coalesce(lim, 10), 1); +$$; + +-- Top countries. tracker_events carries no region/timezone, so the recent +-- twin of tracker_top_countries returns the two columns the page reads. +create or replace function public.tracker_recent_top_countries( + p_project uuid, + p_minutes integer default 60, + 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, count(*)::bigint as total + from public.tracker_events + where project_id = p_project + and coalesce(country_code, '') <> '' + and occurred_at >= now() + - (least(greatest(coalesce(p_minutes, 60), 1), 1440) || ' minutes')::interval + group by country_code + order by total desc + limit greatest(coalesce(lim, 10), 1); +$$; + +-- Top cities. region_code / region_name come back empty because the raw table +-- does not store them; the page already tolerates blank segments in the label. +create or replace function public.tracker_recent_top_cities( + p_project uuid, + p_minutes integer default 60, + 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, + ''::text as region_code, + ''::text as region_name, + max(country_code) as country_code, + max(country_name) as country_name, + count(*)::bigint as total + from public.tracker_events + where project_id = p_project + and coalesce(city, '') <> '' + and occurred_at >= now() + - (least(greatest(coalesce(p_minutes, 60), 1), 1440) || ' minutes')::interval + group by city + order by total desc + limit greatest(coalesce(lim, 10), 1); +$$; + +-- Earliest rollup day, so the "All time" tab can size its axis instead of +-- zero-filling from an arbitrary epoch. Null means the project has no rollups. +create or replace function public.tracker_first_day(p_project uuid) +returns date +language sql +stable +security invoker +set search_path = public +as $$ + select least( + (select min(day) from public.tracker_daily_stats where project_id = p_project), + (select min(day) from public.tracker_event_daily_stats where project_id = p_project) + ); +$$; + +grant execute on function public.tracker_recent_series(uuid, integer, integer) to authenticated, service_role; +grant execute on function public.tracker_recent_bucket_totals(uuid, integer, integer) to authenticated, service_role; +grant execute on function public.tracker_recent_event_mix(uuid, integer) to authenticated, service_role; +grant execute on function public.tracker_recent_top_pages(uuid, integer, integer) to authenticated, service_role; +grant execute on function public.tracker_recent_top_referrers(uuid, integer, integer) to authenticated, service_role; +grant execute on function public.tracker_recent_top_actions(uuid, integer, integer) to authenticated, service_role; +grant execute on function public.tracker_recent_top_countries(uuid, integer, integer) to authenticated, service_role; +grant execute on function public.tracker_recent_top_cities(uuid, integer, integer) to authenticated, service_role; +grant execute on function public.tracker_first_day(uuid) to authenticated, service_role;