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
77 changes: 67 additions & 10 deletions app/(app)/dashboard/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ import {
BOTS_LABEL,
HUMANS_DEFINITION,
HUMANS_LABEL,
VISITORS_DEFINITION,
VISITORS_LABEL,
humansFrom,
} from "@/lib/tracker/humans";
import {
VISITORS_CAPTION,
fetchVisitorTotals,
sumVisitorTotals,
visitorsPartial,
type VisitorTotals,
} from "@/lib/tracker/visitors";
import {
TrackerAnalytics,
type TrackerListItem,
Expand Down Expand Up @@ -71,14 +80,18 @@ type PortfolioProject = {
organization_id?: string | null;
};

// Every figure on this page leads with humans (bucket not `bot:`, AI referrals
// included) and shows bot crawls apart. See lib/tracker/humans.ts.
// Every figure on this page leads with people — distinct visitors from the
// visitor rollup — then human EVENTS (bucket not `bot:`, AI referrals
// included; several beacons per page view) and shows bot crawls apart. See
// lib/tracker/humans.ts and lib/tracker/visitors.ts.
type ProjectRow = {
project: PortfolioProject;
totals: ProjectTotals;
/** Human visits, this window vs the one before. */
/** Distinct human visitors this window vs the one before; null when the rollup is unavailable. */
visitors: VisitorTotals | null;
/** Human events, this window vs the one before. */
trend: Trend;
/** Daily human visits for the sparkline; null when over the row budget. */
/** Daily human events for the sparkline; null when over the row budget. */
samples: number[] | null;
};

Expand Down Expand Up @@ -217,6 +230,22 @@ export default async function PortfolioAnalyticsPage({
const portfolio = sumTotals(totalsByProject.values());
const trends = totalsTrends(portfolio);

// People. One more RPC, kept apart from the eleven above because its
// failure means "no visitor figure", not "no traffic": the tile and the
// column are left out rather than zeroed.
const visitorsByProject = await fetchVisitorTotals(supabase, projectIds, days, "human");
const portfolioVisitors = visitorsByProject
? sumVisitorTotals(visitorsByProject.values())
: null;
const visitorsTrend = portfolioVisitors
? computeTrend(portfolioVisitors.visitors, portfolioVisitors.prevVisitors)
: null;
const visitorsCaption = !visitorsByProject
? "Visitor counts are unavailable right now; the figures below count events."
: visitorsPartial(days)
? VISITORS_CAPTION
: null;

// Ranked by current-window HUMAN volume: the biggest properties get the
// chart bands, and the daily-detail budget is spent on them first. Ranking
// by events would hand the top band to whichever site a crawler is hitting.
Expand Down Expand Up @@ -264,6 +293,9 @@ export default async function PortfolioAnalyticsPage({
return {
project,
totals,
visitors: visitorsByProject
? (visitorsByProject.get(project.id) ?? { visitors: 0, prevVisitors: 0, pageviews: 0, prevPageviews: 0 })
: null,
trend: computeTrend(totals.humans, totals.prevHumans),
samples: byDay ? axis.map((day) => byDay.get(day) ?? 0) : null,
};
Expand Down Expand Up @@ -444,11 +476,19 @@ export default async function PortfolioAnalyticsPage({
</p>
</section>

<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{visitorsTrend && (
<TrendMetric
label={VISITORS_LABEL}
trend={visitorsTrend}
tone="accent"
hint={VISITORS_DEFINITION}
/>
)}
<TrendMetric
label={HUMANS_LABEL}
trend={trends.humans}
tone="accent"
tone={visitorsTrend ? "muted" : "accent"}
hint={HUMANS_DEFINITION}
/>
<TrendMetric
Expand All @@ -470,6 +510,9 @@ export default async function PortfolioAnalyticsPage({
hint={ALL_EVENTS_DEFINITION}
/>
</div>
{visitorsCaption && (
<p className="-mt-1 text-xs text-[var(--color-muted)]">{visitorsCaption}</p>
)}

{portfolio.events === 0 && portfolio.prevEvents === 0 ? (
<section className="card p-4">
Expand All @@ -485,13 +528,21 @@ export default async function PortfolioAnalyticsPage({
<div>
<h2 className="text-lg font-semibold">Portfolio trend</h2>
<p className="text-sm text-[var(--color-muted)]">
Daily human visits, stacked by property. Bot crawls across
Daily human events, stacked by property. Bot crawls across
every property are the dashed line, kept out of the stack.
</p>
</div>
<span className="text-xs text-[var(--color-muted)]">
{portfolioVisitors && (
<>
<span title={VISITORS_DEFINITION}>
{portfolioVisitors.visitors.toLocaleString()} human visitors
</span>
{" · "}
</>
)}
<span title={HUMANS_DEFINITION}>
{portfolio.humans.toLocaleString()} human visits
{portfolio.humans.toLocaleString()} human events
</span>
{" · "}
<span title={BOTS_DEFINITION}>
Expand Down Expand Up @@ -633,8 +684,11 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) {
<thead>
<tr className="border-b border-[var(--color-border)] text-left text-xs text-[var(--color-muted)]">
<th className="py-2 pr-3 font-medium">Property</th>
<th className="py-2 pr-3 text-right font-medium" title={VISITORS_DEFINITION}>
Visitors
</th>
<th className="py-2 pr-3 text-right font-medium" title={HUMANS_DEFINITION}>
Humans
Events
</th>
<th className="py-2 pr-3 text-right font-medium" title={HUMANS_DEFINITION}>
Previous
Expand All @@ -650,7 +704,7 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) {
</tr>
</thead>
<tbody>
{rows.map(({ project, totals, trend, samples }) => (
{rows.map(({ project, totals, visitors, trend, samples }) => (
<tr
key={project.id}
className="border-b border-[var(--color-border)] last:border-0"
Expand All @@ -676,6 +730,9 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) {
</span>
</Link>
</td>
<td className="py-2 pr-3 text-right tabular-nums" title={VISITORS_DEFINITION}>
{visitors ? visitors.visitors.toLocaleString() : "—"}
</td>
<td className="py-2 pr-3 text-right tabular-nums" title={HUMANS_DEFINITION}>
{totals.humans.toLocaleString()}
</td>
Expand Down
46 changes: 39 additions & 7 deletions app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { createClient } from "@/lib/supabase/server";
import { rpcFailed, type Loaded } from "@/lib/loaded";
import { ScoreBadge } from "@/components/score-badge";
import { FontSparkline } from "@/components/font-sparkline";
import { BOTS_DEFINITION, HUMANS_DEFINITION } from "@/lib/tracker/humans";
import { BOTS_DEFINITION, HUMANS_DEFINITION, VISITORS_DEFINITION } from "@/lib/tracker/humans";
import { fetchVisitorDailySeries } from "@/lib/tracker/visitors";
import { ProjectLogo } from "@/components/project-logo";
import { StatsUnavailable } from "@/components/stats-unavailable";
import { backfillProjectLogo } from "@/app/actions/createProject";
Expand Down Expand Up @@ -121,11 +122,12 @@ export default async function DashboardPage({
// project has an lx_site row in status=active; social is "on" when at
// least one social account is linked at the project level.
const projectIds = (projects ?? []).map((p) => p.id);
const [autoblogIds, socialIds, latestPosts, traffic] = await Promise.all([
const [autoblogIds, socialIds, latestPosts, traffic, visitorsByProject] = await Promise.all([
fetchEnabledProjectIds(supabase, "lx_site", projectIds, { status: "active" }),
fetchEnabledProjectIds(supabase, "sp_site_account", projectIds),
fetchLatestBlogPostByProject(supabase, projectIds),
fetchSevenDayTraffic(supabase, projectIds),
fetchSevenDayVisitors(supabase, projectIds),
]);
const trafficByProject = traffic.data;
const trafficFailed = traffic.failed;
Expand Down Expand Up @@ -266,23 +268,27 @@ export default async function DashboardPage({
<div>
<div
className="text-xs font-medium text-[var(--color-fg)]"
title={trafficFailed ? undefined : HUMANS_DEFINITION}
title={trafficFailed ? undefined : visitorsByProject ? VISITORS_DEFINITION : HUMANS_DEFINITION}
>
{trafficFailed
? "Traffic unavailable"
: `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human visits`}
: visitorsByProject
? `${sum(visitorsByProject.get(p.id)).toLocaleString()} human visitors`
: `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human events`}
</div>
<div
className="text-[11px] text-[var(--color-muted)]"
title={trafficFailed ? undefined : BOTS_DEFINITION}
title={trafficFailed ? undefined : `${HUMANS_DEFINITION} ${BOTS_DEFINITION}`}
>
{trafficFailed
? "Query failed \u2014 not zero"
: `${totalBots(trafficByProject.get(p.id) ?? []).toLocaleString()} bot hits \u00b7 Past 7 days`}
: `${visitorsByProject ? `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human events \u00b7 ` : ""}${totalBots(trafficByProject.get(p.id) ?? []).toLocaleString()} bot hits \u00b7 Past 7 days`}
</div>
</div>
{!trafficFailed && (
<FontSparkline samples={humanSamples(trafficByProject.get(p.id))} />
<FontSparkline
samples={visitorsByProject?.get(p.id) ?? humanSamples(trafficByProject.get(p.id))}
/>
)}
</div>
{orgSchemaReady && (
Expand Down Expand Up @@ -436,6 +442,32 @@ async function fetchSevenDayTraffic(
return { data: out, failed: false };
}

// Distinct human visitors per day for the past seven UTC days, per project:
// the number the card leads with. The visitor rollup only began on
// lib/tracker/visitors.ts VISITORS_SINCE, and it counts people where
// dashboard_project_traffic counts beacons (several per page view), which
// is why the two figures on a card differ by a hundredfold and both are
// shown. null when the RPC failed or is not deployed: the card then leads
// with events under their real name rather than showing "0 visitors".
async function fetchSevenDayVisitors(
supabase: Awaited<ReturnType<typeof createClient>>,
projectIds: string[],
): Promise<Map<string, number[]> | null> {
const days = lastSevenDays();
const byProject = await fetchVisitorDailySeries(supabase, projectIds, 7, "human");
if (!byProject) return null;
const out = new Map<string, number[]>();
for (const projectId of projectIds) {
const byDay = byProject.get(projectId);
out.set(projectId, days.map((day) => byDay?.get(day) ?? 0));
}
return out;
}

function sum(samples: number[] | undefined) {
return (samples ?? []).reduce((total, n) => total + n, 0);
}

function lastSevenDays() {
return Array.from({ length: 7 }, (_, index) => {
const date = new Date();
Expand Down
48 changes: 33 additions & 15 deletions app/(app)/dashboard/projects/[id]/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ import {
TrackerAnalytics,
type TrackerPanels,
} from "@/components/charts/tracker-analytics";
import { fetchPanels, PANEL_KEYS } from "@/lib/tracker/panels";
import { fetchPanels, PANEL_KEYS, resolveDays, rollupDays } from "@/lib/tracker/panels";
import { DEFAULT_TRACKER_RANGE, trackerRange } from "@/lib/tracker/ranges";
import { headlineTiles, whoOrDefault, whoToKind } from "@/lib/tracker/who";
import {
VISITORS_CAPTION,
fetchVisitorTotals,
visitorsPartial,
} from "@/lib/tracker/visitors";
import { InstallSnippet } from "./install-snippet";
import { TrackerToggle } from "./tracker-toggle";
import { CareersToggle } from "./careers-toggle";
Expand Down Expand Up @@ -65,31 +70,41 @@ export default async function ProjectStatsPage({
// 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;
// The visitor rollup is day-resolution, so a sub-day range reads today's
// rollup — the same rule the device and exit-page cards apply.
const visitorDays = rollupDays(range, await resolveDays(supabase, id, range));
const [panels, visitorTotals] = await Promise.all([
fetchPanels(supabase, id, PANEL_KEYS, range, kind) as unknown as Promise<TrackerPanels>,
fetchVisitorTotals(supabase, [id], visitorDays, kind),
]);

// 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. Under Humans or
// Bots only that side's tiles are shown (lib/tracker/who.ts).
// Headline metrics: the people first. Distinct visitors and their page
// views come from the visitor rollup (lib/tracker/visitors.ts); the event
// figures come straight from the series so they stay exact even though Top
// sources below is truncated to the top 10 buckets. The event count used to
// be labelled "Human visits" and ran ~100x above the number of people — it
// is stats.js firing several beacons per page view — so it now sits after
// the visitor tiles under its real name. 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 visitors = visitorTotals?.get(id) ?? (visitorTotals ? { visitors: 0, pageviews: 0 } : null);
const tiles = headlineTiles(who, {
visitors: visitors ? visitors.visitors : null,
pageviews: visitors ? visitors.pageviews : null,
humans: totalHuman,
ai: totalAi,
bots: totalBot,
});
const visitorsCaption = visitors === null
? "Visitor counts are unavailable right now; the figures below count events."
: visitorsPartial(visitorDays)
? VISITORS_CAPTION
: null;

// 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 @@ -265,7 +280,7 @@ export default async function ProjectStatsPage({
<WhoToggle value={who} />
</div>

<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{tiles.map((tile) => (
<Metric
key={tile.key}
Expand All @@ -276,6 +291,9 @@ export default async function ProjectStatsPage({
/>
))}
</div>
{visitorsCaption && (
<p className="-mt-1 text-xs text-[var(--color-muted)]">{visitorsCaption}</p>
)}

{grandTotal === 0 && eventTotal === 0 ? (
<section className="card p-4">
Expand Down
Loading
Loading