@@ -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;