Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions app/(app)/dashboard/projects/[id]/stats/live-visitors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from "react";
import { VisitorGlobe, type GlobePoint } from "./visitor-globe";
import { LiveChart } from "./live-chart";
import { DEFAULT_WHO, WHO_PARAM, type Who } from "@/lib/tracker/who";

type LiveEvent = {
id: number;
Expand Down Expand Up @@ -48,7 +49,14 @@ function flagEmoji(code: string) {
);
}

export function LiveVisitors({ projectId }: { projectId: string }) {
export function LiveVisitors({
projectId,
who = DEFAULT_WHO,
}: {
projectId: string;
/** The page-wide Humans / Bots / All toggle; the feed is filtered to it. */
who?: Who;
}) {
const [data, setData] = useState<LiveData | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
Expand All @@ -68,7 +76,7 @@ export function LiveVisitors({ projectId }: { projectId: string }) {
const fetchData = useCallback(async () => {
try {
const res = await fetch(
`/api/projects/${projectId}/live-events?minutes=${minutes}`,
`/api/projects/${projectId}/live-events?minutes=${minutes}&${WHO_PARAM}=${who}`,
{ cache: "no-store" },
);
if (!res.ok) return;
Expand All @@ -80,7 +88,7 @@ export function LiveVisitors({ projectId }: { projectId: string }) {
} finally {
setLoading(false);
}
}, [projectId, minutes]);
}, [projectId, minutes, who]);

useEffect(() => {
fetchData();
Expand All @@ -102,6 +110,11 @@ export function LiveVisitors({ projectId }: { projectId: string }) {
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<h2 className="text-lg font-semibold">Live</h2>
{who !== "all" && (
<span className="rounded border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-muted)]">
{who}
</span>
)}
{lastUpdated && (
<span className="text-xs text-[var(--color-muted)]">
· refreshes every 30s · {relTime(lastUpdated.toISOString())}
Expand Down Expand Up @@ -148,7 +161,10 @@ export function LiveVisitors({ projectId }: { projectId: string }) {
<p className="text-xs text-[var(--color-muted)] px-3 pb-2">Loading…</p>
) : totalEvents === 0 ? (
<p className="text-xs text-[var(--color-muted)] px-3 pb-2">
No events in the last {minutes} min. Traffic will appear once the tracker script fires.
No {who === "all" ? "" : who === "humans" ? "human " : "bot "}events in the last {minutes} min.{" "}
{who === "all"
? "Traffic will appear once the tracker script fires."
: "Switch to All to see everything the tracker has recorded."}
</p>
) : (
<>
Expand Down
82 changes: 54 additions & 28 deletions app/(app)/dashboard/projects/[id]/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,15 @@ import {
type TrackerPanels,
} from "@/components/charts/tracker-analytics";
import { fetchPanels, PANEL_KEYS } from "@/lib/tracker/panels";
import {
AI_REFERRALS_DEFINITION,
BOTS_DEFINITION,
BOTS_LABEL,
HUMANS_DEFINITION,
HUMANS_LABEL,
} from "@/lib/tracker/humans";
import { DEFAULT_TRACKER_RANGE, trackerRange } from "@/lib/tracker/ranges";
import { headlineTiles, whoOrDefault, whoToKind } from "@/lib/tracker/who";
import { InstallSnippet } from "./install-snippet";
import { TrackerToggle } from "./tracker-toggle";
import { CareersToggle } from "./careers-toggle";
import { AutoInstall } from "./auto-install";
import { LiveVisitors } from "./live-visitors";
import { StatsSubnav } from "./stats-subnav";
import { WhoToggle } from "./who-toggle";
import { getOrMintInstallationToken } from "@/lib/github/installations";
import { listInstallationRepos } from "@/lib/github/app";

Expand All @@ -36,10 +31,17 @@ type BoundRepo = {

export default async function ProjectStatsPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ who?: string }>;
}) {
const { id } = await params;
// The page-wide Humans / Bots / All toggle. Lives in the URL so a link to
// the page carries the reader's choice; junk falls back to humans, the
// side the page leads with. See lib/tracker/who.ts.
const who = whoOrDefault((await searchParams).who);
const kind = whoToKind(who);
const supabase = await createClient();
const { data: project } = await supabase
.from("projects")
Expand All @@ -59,25 +61,35 @@ export default async function ProjectStatsPage({
// 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.
//
// Every panel is fetched at the toggle's `kind`, so a card and the tiles
// above it never disagree on who they are counting.
const range = trackerRange(DEFAULT_TRACKER_RANGE);
const panels = (await fetchPanels(
supabase,
id,
PANEL_KEYS,
range,
kind,
)) 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. The page
// leads with humans (every bucket that is not `bot:`, AI referrals
// included) and shows bot crawls apart — see lib/tracker/humans.ts for why
// the old bot-inclusive total is no longer a headline.
// the old bot-inclusive total is no longer a headline. Under Humans or
// Bots only that side's tiles are shown (lib/tracker/who.ts).
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 totalHuman = points.reduce((s, p) => s + p.humans, 0);
const grandTotal = points.reduce((s, p) => s + p.events, 0);
const eventTotal = points.reduce((s, p) => s + p.pageviews + p.interactions, 0);
const tiles = headlineTiles(who, {
humans: totalHuman,
ai: totalAi,
bots: totalBot,
});

// 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
Expand Down Expand Up @@ -160,7 +172,7 @@ export default async function ProjectStatsPage({
<div className="space-y-6">
<StatsSubnav projectId={id} />

<LiveVisitors projectId={id} />
<LiveVisitors projectId={id} who={who} />

<section className="card p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-3">
Expand Down Expand Up @@ -237,40 +249,54 @@ export default async function ProjectStatsPage({
</section>
)}

<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">Traffic</h2>
<p className="text-sm text-[var(--color-muted)]">
{range.description}. Every figure below counts{" "}
{who === "humans"
? "people only"
: who === "bots"
? "crawlers only"
: "people and crawlers together"}
.
</p>
</div>
<WhoToggle value={who} />
</div>

<div className="grid gap-3 sm:grid-cols-3">
<Metric
label={HUMANS_LABEL}
value={totalHuman}
tone="accent"
hint={HUMANS_DEFINITION}
/>
<Metric
label="AI referrals"
value={totalAi}
tone="pass"
hint={AI_REFERRALS_DEFINITION}
/>
<Metric
label={BOTS_LABEL}
value={totalBot}
tone="warn"
hint={BOTS_DEFINITION}
/>
{tiles.map((tile) => (
<Metric
key={tile.key}
label={tile.label}
value={tile.value}
tone={tile.tone}
hint={tile.hint}
/>
))}
</div>

{grandTotal === 0 && eventTotal === 0 ? (
<section className="card p-4">
<p className="text-sm text-[var(--color-muted)]">
{trackerEnabled
? "No events yet. Once the snippet is installed and your site gets traffic, sources will appear here."
? who === "all"
? "No events yet. Once the snippet is installed and your site gets traffic, sources will appear here."
: `No ${who === "humans" ? "human" : "bot"} events in this window. Switch to All to see everything the tracker has recorded.`
: "Enable the tracker to start collecting events."}
</p>
</section>
) : (
/* Keyed on the toggle: a change re-renders this page with panels
fetched at the new filter, and the remount hands every card that
fresh initial data instead of a stale cache. */
<TrackerAnalytics
key={who}
projectId={id}
initial={panels}
initialRange={range.key}
who={who}
/>
)}
</div>
Expand Down
102 changes: 102 additions & 0 deletions app/(app)/dashboard/projects/[id]/stats/who-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use client";

import { useRef, useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
DEFAULT_WHO,
WHO_OPTIONS,
WHO_PARAM,
whoCaption,
type Who,
} from "@/lib/tracker/who";

// The page-wide Humans / Bots / All control on the project stats page.
//
// The selection lives in the URL (?who=), not in component state: the page
// is a server component that reads it, fetches every panel and the headline
// tiles at that filter, and keys the chart cards on it so they remount on
// fresh data. This control only navigates. Replacing rather than pushing
// keeps the back button for leaving the page, not for undoing a toggle, and
// the transition dims the strip while the new numbers are on their way so a
// click never looks ignored.
//
// Same visual language as the timeframe tabs in each card header, with
// arrow-key roving so keyboard users are not stranded on the selected option.
export function WhoToggle({ value }: { value: Who }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, start] = useTransition();
const refs = useRef<Array<HTMLButtonElement | null>>([]);

function select(next: Who) {
if (next === value) return;
const params = new URLSearchParams(searchParams?.toString());
if (next === DEFAULT_WHO) params.delete(WHO_PARAM);
else params.set(WHO_PARAM, next);
const qs = params.toString();
start(() => {
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
});
}

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 + WHO_OPTIONS.length) % WHO_OPTIONS.length;
refs.current[next]?.focus();
select(WHO_OPTIONS[next].key);
}

const caption = whoCaption(value);

return (
<div className="flex flex-col items-end gap-1">
<div
role="tablist"
aria-label="Who to count"
aria-busy={pending}
className={[
"inline-flex shrink-0 rounded-md border border-[var(--color-border)] p-0.5 transition-opacity",
pending ? "opacity-50" : "",
].join(" ")}
>
{WHO_OPTIONS.map((option, index) => {
const selected = option.key === value;
return (
<button
key={option.key}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
title={option.description}
disabled={pending}
onClick={() => select(option.key)}
onKeyDown={(e) => onKeyDown(e, index)}
className={[
"rounded px-2 py-0.5 text-xs font-medium transition-colors",
"disabled:cursor-wait",
selected
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-muted)] hover:text-[var(--color-fg)]",
].join(" ")}
>
{option.label}
</button>
);
})}
</div>
{caption && (
<p className="text-[11px] leading-snug text-[var(--color-muted)]">
{caption}
</p>
)}
</div>
);
}
26 changes: 22 additions & 4 deletions app/api/projects/[id]/live-events/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// GET /api/projects/[id]/live-events?minutes=30
// Returns recent raw tracker events for the real-time stats panel.
// GET /api/projects/[id]/live-events?minutes=30&who=humans
// Returns recent raw tracker events for the real-time stats panel. `who` is
// the page-wide Humans / Bots / All toggle (lib/tracker/who.ts) and filters
// on the bucket prefix, which the raw row has always carried; junk is a 400.
// Requires project owner or member auth.

import { NextRequest, NextResponse } from "next/server";
import { requireProjectAccess } from "@/lib/lx/currentSite";
import { serviceClient } from "@/lib/supabase/service";
import { bucketLabel } from "@/lib/tracker/categorize";
import { DEFAULT_WHO, parseWho, WHO_PARAM, whoToKind } from "@/lib/tracker/who";

// Approximate country centroids — fallback when precise lat/lng is missing.
const COUNTRY_CENTROIDS: Record<string, [number, number]> = {
Expand Down Expand Up @@ -47,14 +50,28 @@ export async function GET(
);
const since = new Date(Date.now() - minutes * 60 * 1000).toISOString();

const whoParam = request.nextUrl.searchParams.get(WHO_PARAM);
const who = whoParam === null ? DEFAULT_WHO : parseWho(whoParam);
if (!who) {
return NextResponse.json(
{ error: "Unknown who. Expected humans, bots or all." },
{ status: 400 },
);
}
const kind = whoToKind(who);

const svc = serviceClient();
const { data: rows, error } = await svc
let query = svc
.from("tracker_events")
.select(
"id, occurred_at, event, page_path, referrer_host, event_target, bucket, country_code, country_name, city, lat, lng, visitor_id, session_id",
)
.eq("project_id", projectId)
.gte("occurred_at", since)
.gte("occurred_at", since);
// Same rule as the RPCs' p_kind: bot iff the bucket starts with "bot:".
if (kind === "bot") query = query.like("bucket", "bot:%");
if (kind === "human") query = query.not("bucket", "like", "bot:%");
const { data: rows, error } = await query
.order("occurred_at", { ascending: false })
.limit(500);

Expand Down Expand Up @@ -131,6 +148,7 @@ export async function GET(

return NextResponse.json({
minutes,
who,
total_events: events.length,
unique_sessions: sessionsSeen.size,
// Up to 300 so the live chart can bucket a fuller slice of the window;
Expand Down
Loading
Loading