diff --git a/app/(app)/dashboard/analytics/page.tsx b/app/(app)/dashboard/analytics/page.tsx
index 4c8edad..7b7a649 100644
--- a/app/(app)/dashboard/analytics/page.tsx
+++ b/app/(app)/dashboard/analytics/page.tsx
@@ -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,
@@ -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;
};
@@ -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.
@@ -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,
};
@@ -444,11 +476,19 @@ export default async function PortfolioAnalyticsPage({
-
+
+ {visitorsTrend && (
+
+ )}
+ {visitorsCaption && (
+
{visitorsCaption}
+ )}
{portfolio.events === 0 && portfolio.prevEvents === 0 ? (
@@ -485,13 +528,21 @@ export default async function PortfolioAnalyticsPage({
Portfolio trend
- 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.
+ {portfolioVisitors && (
+ <>
+
+ {portfolioVisitors.visitors.toLocaleString()} human visitors
+
+ {" · "}
+ >
+ )}
- {portfolio.humans.toLocaleString()} human visits
+ {portfolio.humans.toLocaleString()} human events
{" · "}
@@ -633,8 +684,11 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) {
| Property |
+
+ Visitors
+ |
- Humans
+ Events
|
Previous
@@ -650,7 +704,7 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) {
|
- {rows.map(({ project, totals, trend, samples }) => (
+ {rows.map(({ project, totals, visitors, trend, samples }) => (
+ |
+ {visitors ? visitors.visitors.toLocaleString() : "—"}
+ |
{totals.humans.toLocaleString()}
|
diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
index 5bd7b40..2a541e4 100644
--- a/app/(app)/dashboard/page.tsx
+++ b/app/(app)/dashboard/page.tsx
@@ -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";
@@ -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;
@@ -266,23 +268,27 @@ export default async function DashboardPage({
{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`}
{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`}
{!trafficFailed && (
-
+
)}
{orgSchemaReady && (
@@ -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>,
+ projectIds: string[],
+): Promise