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
771 changes: 771 additions & 0 deletions app/(app)/analytics/page.tsx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
</Link>
<nav className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-[var(--color-muted)]">
<Link href="/dashboard">Dashboard</Link>
<Link href="/analytics">Analytics</Link>
<Link href="/alerts">Alerts</Link>
<Link href="/recent">Recent</Link>
<Link href="/projects/new">New</Link>
Expand Down
73 changes: 5 additions & 68 deletions app/(app)/projects/[id]/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { DEFAULT_PROJECT_ENGINES, type Engine } from "@/lib/credits";
import type { ProjectStatus } from "@/app/actions/projects";
import {
TrackerAnalytics,
type TrackerDailyPoint,
type TrackerListItem,
} from "@/components/charts/tracker-analytics";
import { buildDailyAxis, toSeriesRow } from "@/lib/tracker/series";
import { InstallSnippet } from "./install-snippet";
import { TrackerToggle } from "./tracker-toggle";
import { CareersToggle } from "./careers-toggle";
Expand All @@ -21,15 +21,8 @@ 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, so we coerce with Number() at the call site.
type SeriesRow = {
day: string;
pageviews: number | string;
interactions: number | string;
ai: number | string;
bots: number | string;
events: number | string;
};
// strings over PostgREST; toSeriesRow() coerces them.
type SeriesRow = Parameters<typeof toSeriesRow>[0];
type BoundRepo = {
id: string;
full_name: string;
Expand Down Expand Up @@ -91,15 +84,8 @@ export default async function ProjectStatsPage({
supabase.rpc("tracker_device_totals", { p_project: id, days }),
]);

const series = ((seriesRes.data ?? []) as SeriesRow[]).map((r) => ({
day: r.day,
pageviews: Number(r.pageviews),
interactions: Number(r.interactions),
ai: Number(r.ai),
bots: Number(r.bots),
events: Number(r.events),
}));
const daily = buildDaily(series);
const series = ((seriesRes.data ?? []) as SeriesRow[]).map(toSeriesRow);
const daily = buildDailyAxis(series, WINDOW_DAYS);

// 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"
Expand Down Expand Up @@ -443,55 +429,6 @@ function ConnectedRepos({
);
}

// Map the per-day series onto a zero-filled WINDOW_DAYS axis (UTC) so gaps
// render as flat spans rather than being skipped. Days beyond the RPC's own
// window (should be none) are ignored.
function buildDaily(
series: Array<{
day: string;
pageviews: number;
interactions: number;
ai: number;
bots: number;
events: number;
}>,
): TrackerDailyPoint[] {
const start = new Date();
start.setUTCHours(0, 0, 0, 0);
start.setUTCDate(start.getUTCDate() - WINDOW_DAYS + 1);

const byDay = new Map<string, TrackerDailyPoint>();
for (let i = 0; i < WINDOW_DAYS; i++) {
const d = new Date(start);
d.setUTCDate(start.getUTCDate() + i);
const date = d.toISOString().slice(0, 10);
byDay.set(date, {
date,
events: 0,
pageviews: 0,
interactions: 0,
ai: 0,
bots: 0,
});
}

for (const row of series) {
const point = byDay.get(row.day);
if (!point) continue;
point.pageviews += row.pageviews;
point.interactions += row.interactions;
point.ai += row.ai;
point.bots += row.bots;
point.events += row.events;
}

for (const point of byDay.values()) {
if (point.events === 0) point.events = point.pageviews + point.interactions;
}

return Array.from(byDay.values());
}

function topDeviceItems(
rows: DeviceRow[],
labelFor: (row: DeviceRow) => string,
Expand Down
113 changes: 113 additions & 0 deletions components/charts/portfolio-trend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"use client";

import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";

// One stacked band per property, so the total height is the portfolio and each
// band shows which site is actually moving it. Series keys are project ids
// (plus the synthetic OTHER_KEY band) because project names are not unique.
export type PortfolioSeries = {
key: string;
name: string;
};

export type PortfolioPoint = { date: string } & Record<string, number | string>;

export const OTHER_KEY = "__other";

const COLORS = [
"var(--color-accent)",
"#60a5fa",
"var(--color-warn)",
"#f472b6",
"#a78bfa",
"#2dd4bf",
];

const OTHER_COLOR = "var(--color-muted)";

export function PortfolioTrend({
data,
series,
}: {
data: PortfolioPoint[];
series: PortfolioSeries[];
}) {
return (
<div className="h-72 min-h-72 min-w-0">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={288}
initialDimension={{ width: 640, height: 288 }}
>
<AreaChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: -20 }}>
<CartesianGrid
stroke="var(--color-border)"
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
dataKey="date"
stroke="var(--color-muted)"
tick={{ fontSize: 11 }}
tickFormatter={formatDate}
/>
<YAxis stroke="var(--color-muted)" tick={{ fontSize: 11 }} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={formatLongDate} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{series.map((entry, index) => {
const color =
entry.key === OTHER_KEY
? OTHER_COLOR
: COLORS[index % COLORS.length];
return (
<Area
key={entry.key}
type="monotone"
dataKey={entry.key}
name={entry.name}
stackId="portfolio"
stroke={color}
fill={color}
fillOpacity={entry.key === OTHER_KEY ? 0.14 : 0.26}
isAnimationActive={false}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</div>
);
}

const tooltipStyle = {
background: "var(--color-card)",
border: "1px solid var(--color-border)",
borderRadius: 8,
fontSize: 12,
};

function formatDate(value: string) {
return new Date(value).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}

function formatLongDate(value: unknown) {
return new Date(String(value)).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
93 changes: 93 additions & 0 deletions lib/tracker/series.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Shared shaping for the tracker_daily_series / tracker_daily_series_multi
// RPCs. Both the per-project stats page and the portfolio analytics page map
// the sparse per-day rows onto a zero-filled UTC axis so gaps render as flat
// spans rather than being skipped entirely.

export type TrackerSeriesRow = {
day: string;
pageviews: number;
interactions: number;
ai: number;
bots: number;
events: number;
};

export type TrackerDailyPointShape = {
date: string;
events: number;
pageviews: number;
interactions: number;
ai: number;
bots: number;
};

// Coerce a raw RPC row. bigint columns arrive as strings over PostgREST.
export function toSeriesRow(row: {
day: string;
pageviews: number | string;
interactions: number | string;
ai: number | string;
bots: number | string;
events: number | string;
}): TrackerSeriesRow {
return {
day: row.day,
pageviews: Number(row.pageviews),
interactions: Number(row.interactions),
ai: Number(row.ai),
bots: Number(row.bots),
events: Number(row.events),
};
}

// The last `days` UTC calendar days, oldest first, as YYYY-MM-DD.
export function utcDayAxis(days: number, now = new Date()): string[] {
const span = Math.max(1, days);
const start = new Date(now);
start.setUTCHours(0, 0, 0, 0);
start.setUTCDate(start.getUTCDate() - span + 1);

return Array.from({ length: span }, (_, i) => {
const d = new Date(start);
d.setUTCDate(start.getUTCDate() + i);
return d.toISOString().slice(0, 10);
});
}

// Map the per-day series onto a zero-filled `days`-wide axis. Rows outside the
// window (should be none — the RPC applies the same window) are ignored.
export function buildDailyAxis(
series: TrackerSeriesRow[],
days: number,
now = new Date(),
): TrackerDailyPointShape[] {
const byDay = new Map<string, TrackerDailyPointShape>();
for (const date of utcDayAxis(days, now)) {
byDay.set(date, {
date,
events: 0,
pageviews: 0,
interactions: 0,
ai: 0,
bots: 0,
});
}

for (const row of series) {
const point = byDay.get(row.day);
if (!point) continue;
point.pageviews += row.pageviews;
point.interactions += row.interactions;
point.ai += row.ai;
point.bots += row.bots;
point.events += row.events;
}

// Older rollups predate the bucket table, so `events` can be 0 on a day that
// clearly had traffic. Fall back to the event table's own totals.
for (const point of byDay.values()) {
if (point.events === 0) point.events = point.pageviews + point.interactions;
}

return Array.from(byDay.values());
}
Loading
Loading