diff --git a/AGENTS.md b/AGENTS.md index 2f24839..4ab41a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,8 @@ Keep it simple. The project is only: an SDK to send events, examples, and a dash - `packages/analytics/`: the publishable SDK (`@rawtree/analytics`). `src/protocol.ts` is the event contract; its types and validator are the source of truth for the SDK and the collector. - `app/api/collect/route.ts` + `lib/collect.ts`, `lib/rawtree.ts`: the Next.js collector. -- `lib/queries.ts`: every dashboard SQL query (one documented template per widget; aliases are the fields `app/page.tsx` reads). `lib/dashboard.ts` runs them; pages import only `lib/dashboard.ts`. Bot detection lives in `lib/crawlers.ts`, date ranges in `lib/range.ts`. -- `app/console/` + `components/console/`: the test console page (`/console`). It sends through the workspace SDK to `app/api/console/collect` (same `handleCollect`, with the visitor's `access.ingest`); `app/api/console/server-event` runs the server SDK in-process and `app/api/console/stored` is the storage check (`access.query`). +- `lib/queries.ts`: every dashboard SQL query (one documented template per widget; aliases are the fields `app/(dashboard)/page.tsx` reads). `lib/dashboard.ts` runs them; pages import only `lib/dashboard.ts`. Bot detection lives in `lib/crawlers.ts`, date ranges in `lib/range.ts`. +- `app/(dashboard)/console/` + `components/console/`: the test console page (`/console`). It sends through the workspace SDK to `app/api/console/collect` (same `handleCollect`, with the visitor's `access.ingest`); `app/api/console/server-event` runs the server SDK in-process and `app/api/console/stored` is the storage check (`access.query`). - `examples/recipes/`: tracking recipes (`RECIPES.md` + `.ts` modules), typechecked by `npm run typecheck`. - `test/`: collector tests. diff --git a/README.md b/README.md index e0f789e..8416ff2 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Everything you send goes to the database you're signed in to (or `RAWTREE_DATABA - **Humans only:** once events carry a user agent, every section except Bots excludes them. Before that, the sections show an "All traffic" badge. - **Sections:** Overview has the headline numbers. Traffic has the daily trend and breakdown. Acquisition shows channels, referrers, and UTM campaigns by each session's first touch. Content lists top pages. Engagement covers time on page, scroll depth, and CTA clicks. Bots shows bot page requests, crawler types, top crawlers, and the most crawled paths. - **Recordings:** pick a session from the list on the left and replay it. Pause, seek, and change speed from the controls under the player. Incomplete recordings replay only their complete stretches and say what was skipped. -- **Tune a widget:** every dashboard query lives in [`lib/queries.ts`](lib/queries.ts), one documented template per widget. Edit the SQL there (for example add a channel host to `SEARCH_HOSTS` or change `ENGAGED_MS`). The aliases are the field names the page reads: if you add or rename one, update that query's row type in `lib/dashboard.ts` and its use in `app/page.tsx`. +- **Tune a widget:** every dashboard query lives in [`lib/queries.ts`](lib/queries.ts), one documented template per widget. Edit the SQL there (for example add a channel host to `SEARCH_HOSTS` or change `ENGAGED_MS`). The aliases are the field names the page reads: if you add or rename one, update that query's row type in `lib/dashboard.ts` and its use in `app/(dashboard)/page.tsx`. ## ☁️ Deploy the dashboard diff --git a/app/console/page.tsx b/app/(dashboard)/console/page.tsx similarity index 73% rename from app/console/page.tsx rename to app/(dashboard)/console/page.tsx index abc207c..19f0449 100644 --- a/app/console/page.tsx +++ b/app/(dashboard)/console/page.tsx @@ -1,8 +1,8 @@ // Test console: send events, simulated traffic, and recordings through the real SDK and // watch each one reach RawTree. Writes go only to the signed-in visitor's own database. -import { ConsoleApp } from "../../components/console/console-app.tsx"; -import { requireAccess } from "../../lib/access.ts"; +import { ConsoleApp } from "../../../components/console/console-app.tsx"; +import { requireAccess } from "../../../lib/access.ts"; export const dynamic = "force-dynamic"; diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..6d6de4f --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,53 @@ +// App shell ported from Treewatcher (components/app-shell.tsx): a floating card sidebar +// with the RawTree logo and navigation. Each page renders its own sticky toolbar, +// because only pages receive search params (the date range). /sign-in sits outside this group. +import Image from "next/image"; +import rawtreeLogo from "../../components/rawtree-logo.svg"; +import { IconExternalLink, IconLogout } from "@tabler/icons-react"; +import { SidebarNav } from "../../components/sidebar-nav.tsx"; +import { requireAccess } from "../../lib/access.ts"; +import { signOut } from "../sign-in/actions.ts"; + +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { + const access = await requireAccess(); + return ( +
+ + {/* pt-3 matches the sidebar's m-3, so the PageToolbar card lines up with the sidebar card. */} +
{children}
+
+ ); +} diff --git a/app/page.tsx b/app/(dashboard)/page.tsx similarity index 98% rename from app/page.tsx rename to app/(dashboard)/page.tsx index ef331ce..0f875d6 100644 --- a/app/page.tsx +++ b/app/(dashboard)/page.tsx @@ -3,13 +3,13 @@ // Treewatcher's growth dashboard. All SQL lives in lib/queries.ts (run by getDashboard); // this page only shapes rows for display. Every count is deduplicated by ID in SQL. -import { requireAccess, signInAgainIfRejected } from "../lib/access.ts"; -import { getDashboard, queryErrorMessage, type DashboardData, type DayRow, type Totals } from "../lib/dashboard.ts"; -import { summarizeCrawlers } from "../lib/crawlers.ts"; -import { DAY_MS, previousRange, resolveRange, toUtcDay, type ResolvedRange, type SearchParams } from "../lib/range.ts"; -import { formatValue, ratio, relativeChange } from "../lib/format.ts"; -import { DateFilter } from "../components/date/date-filter.tsx"; -import { DataTableCard, DonutChart, MetricChartCard, StatCard, type ChartRow } from "../components/charts.tsx"; +import { requireAccess, signInAgainIfRejected } from "../../lib/access.ts"; +import { getDashboard, queryErrorMessage, type DashboardData, type DayRow, type Totals } from "../../lib/dashboard.ts"; +import { summarizeCrawlers } from "../../lib/crawlers.ts"; +import { DAY_MS, previousRange, resolveRange, toUtcDay, type ResolvedRange, type SearchParams } from "../../lib/range.ts"; +import { formatValue, ratio, relativeChange } from "../../lib/format.ts"; +import { DateFilter } from "../../components/date/date-filter.tsx"; +import { DataTableCard, DonutChart, MetricChartCard, StatCard, type ChartRow } from "../../components/charts.tsx"; import { Badge, BarListCard, @@ -23,7 +23,7 @@ import { StatGrid, chartColors, crawlerCategoryColors, -} from "../components/ui.tsx"; +} from "../../components/ui.tsx"; export const dynamic = "force-dynamic"; diff --git a/app/recordings/[id]/page.tsx b/app/(dashboard)/recordings/[id]/page.tsx similarity index 94% rename from app/recordings/[id]/page.tsx rename to app/(dashboard)/recordings/[id]/page.tsx index 87bb639..cbd4e2f 100644 --- a/app/recordings/[id]/page.tsx +++ b/app/(dashboard)/recordings/[id]/page.tsx @@ -3,11 +3,11 @@ // /api/recordings/[id] in bounded parts. import Link from "next/link"; -import { requireAccess, signInAgainIfRejected } from "../../../lib/access.ts"; -import { getRecording, queryErrorMessage, type RecordingListItem } from "../../../lib/dashboard.ts"; -import { formatBytes, formatDateTime, formatDuration, formatValue } from "../../../lib/format.ts"; -import { Badge, ErrorCard, PageToolbar, cn } from "../../../components/ui.tsx"; -import { RecordingPlayer } from "../../../components/recording-player.tsx"; +import { requireAccess, signInAgainIfRejected } from "../../../../lib/access.ts"; +import { getRecording, queryErrorMessage, type RecordingListItem } from "../../../../lib/dashboard.ts"; +import { formatBytes, formatDateTime, formatDuration, formatValue } from "../../../../lib/format.ts"; +import { Badge, ErrorCard, PageToolbar, cn } from "../../../../components/ui.tsx"; +import { RecordingPlayer } from "../../../../components/recording-player.tsx"; export const dynamic = "force-dynamic"; diff --git a/app/recordings/page.tsx b/app/(dashboard)/recordings/page.tsx similarity index 85% rename from app/recordings/page.tsx rename to app/(dashboard)/recordings/page.tsx index 1e03293..0e82d68 100644 --- a/app/recordings/page.tsx +++ b/app/(dashboard)/recordings/page.tsx @@ -2,9 +2,9 @@ // (/recordings/[id], with the list in its sidebar), or shows why there is nothing to play. import { redirect } from "next/navigation"; -import { requireAccess, signInAgainIfRejected } from "../../lib/access.ts"; -import { getLatestRecordingId, queryErrorMessage } from "../../lib/dashboard.ts"; -import { CardEmpty, DashboardCard, ErrorCard, PageToolbar } from "../../components/ui.tsx"; +import { requireAccess, signInAgainIfRejected } from "../../../lib/access.ts"; +import { getLatestRecordingId, queryErrorMessage } from "../../../lib/dashboard.ts"; +import { CardEmpty, DashboardCard, ErrorCard, PageToolbar } from "../../../components/ui.tsx"; export const dynamic = "force-dynamic"; diff --git a/app/layout.tsx b/app/layout.tsx index 83428c9..33b31e1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,17 +1,9 @@ -// App shell ported from Treewatcher (components/app-shell.tsx): a fixed left sidebar -// with the RawTree logo and navigation. Each page renders its own sticky toolbar, -// because only pages receive search params (the date range). Signed out (sign-in mode), -// there is no sidebar: the only page that renders is /sign-in, which brings its own header and footer. +// Root document. The sidebar shell lives in app/(dashboard)/layout.tsx so /sign-in never +// shows it, even while an expired session cookie is still set. import type { Metadata } from "next"; -import Image from "next/image"; import "@fontsource-variable/geist"; import "@fontsource-variable/geist-mono"; import "./globals.css"; -import rawtreeLogo from "../components/rawtree-logo.svg"; -import { IconExternalLink } from "@tabler/icons-react"; -import { SidebarNav } from "../components/sidebar-nav.tsx"; -import { getAccess } from "../lib/access.ts"; -import { signOut } from "./sign-in/actions.ts"; const title = "RawTree Web Analytics"; const description = "Dashboard and replay for the events collected by @rawtree/analytics."; @@ -24,58 +16,10 @@ export const metadata: Metadata = { twitter: { card: "summary_large_image", title, description }, }; -export default async function RootLayout({ children }: { children: React.ReactNode }) { - const access = await getAccess(); - if (!access) { - return ( - - - {children} - - - ); - } +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - -
- - {/* PageToolbar mirrors this padding with negative margins to bleed to the edges; keep them in sync. */} -
{children}
-
- + {children} ); } diff --git a/components/charts.tsx b/components/charts.tsx index 8d62ed2..139f788 100644 --- a/components/charts.tsx +++ b/components/charts.tsx @@ -3,7 +3,7 @@ // segmented-control). Annotations, dialogs, and tooltips-as-library are dropped. "use client"; -import { type ReactNode, useId, useState } from "react"; +import { type ReactNode, useState } from "react"; import { Area, AreaChart, CartesianGrid, ComposedChart, Line, Pie, PieChart, Tooltip, XAxis, YAxis } from "recharts"; import { formatDay, formatLongDay, formatValue, relativeChange, type ValueFormat } from "../lib/format.ts"; import { Tooltip as HoverTip } from "./tooltip.tsx"; @@ -11,12 +11,13 @@ import { CardEmpty, ChartSwatch, DashboardCard, Delta, InfoTip, SegmentedControl const previousPeriodInfo = "Change versus the previous period of the same length."; // Dotted underline (a border, since text-decoration skips the inline-flex Delta) marks the change as hoverable. -const deltaTrigger = "inline-flex cursor-help border-b border-dotted border-muted-foreground/60 pb-px outline-none hover:border-muted-foreground focus-visible:rounded-xs focus-visible:ring-2 focus-visible:ring-ring"; +// Lifted 6px so the underline, not the text baseline, lines up with the bottom of the number beside it +// (top, not translate: a transform would become the containing block of the fixed tooltip). +const deltaTrigger = "relative -top-1.5 inline-flex cursor-help border-b border-dotted border-muted-foreground/60 pb-px outline-none hover:border-muted-foreground focus-visible:rounded-xs focus-visible:ring-2 focus-visible:ring-ring"; const tooltipClass = "min-w-44 max-w-72 rounded-lg border bg-card px-3 py-2.5 text-xs text-card-foreground shadow-popover"; /** Decorative daily trend for stat cards. */ function Sparkline({ values, color = chartColors.primary, label }: { values: readonly number[]; color?: string; label: string }) { - const gradient = `spark-${useId().replace(/:/g, "")}`; return ( - - - - - - - + ); } @@ -68,29 +63,24 @@ export function StatCard({ }) { return (
1 ? "pb-11" : "pb-3.5", - )} + className="flex min-h-34 flex-col gap-1.5 rounded-2xl bg-card px-4.5 pt-4 pb-4 text-card-foreground shadow-soft ring-1 ring-foreground/10" data-testid={`stat-${label.toLowerCase().replace(/ /g, "-")}`} >

{label}

{info ? {info} : null}
-

{formatValue(value, format)}

-
+
+

{formatValue(value, format)}

{change !== undefined ? ( - <> - - - - + + + ) : null} - {hint ? {hint} : null}
+ {hint ?

{hint}

: null} {trend && trend.length > 1 ? ( -
+
) : null} @@ -188,21 +178,12 @@ function TimeSeriesChart({ showComparison: boolean; label: string; }) { - const gradientPrefix = `ts-${useId().replace(/:/g, "")}`; return (
- - {series.map((item) => ( - - - - - ))} - +
Test events} diff --git a/components/sidebar-nav.tsx b/components/sidebar-nav.tsx index ace2f11..42c137a 100644 --- a/components/sidebar-nav.tsx +++ b/components/sidebar-nav.tsx @@ -63,7 +63,7 @@ function useActiveSection(enabled: boolean) { } const itemClass = - "flex min-h-9 items-center gap-2.5 rounded-full px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary max-md:justify-center"; + "flex min-h-9 items-center gap-2.5 rounded-lg px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground aria-[current=page]:bg-sidebar-accent aria-[current=page]:text-sidebar-foreground max-md:justify-center"; export function SidebarNav() { const pathname = usePathname(); @@ -90,7 +90,7 @@ export function SidebarNav() { "block rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground hover:bg-sidebar-accent hover:text-foreground max-md:whitespace-nowrap", // The active marker sits on the list's left border: pl-2 plus the 1px border. active === id && - "relative font-semibold text-foreground before:absolute before:inset-y-1.5 before:-left-2.25 before:w-0.5 before:rounded-xs before:bg-primary max-md:bg-muted max-md:before:hidden", + "relative font-semibold text-foreground before:absolute before:inset-y-1.5 before:-left-2.25 before:w-0.5 before:rounded-xs before:bg-sidebar-foreground max-md:bg-muted max-md:before:hidden", )} href={`#${id}`} > diff --git a/components/tooltip.tsx b/components/tooltip.tsx index 43db892..11df26d 100644 --- a/components/tooltip.tsx +++ b/components/tooltip.tsx @@ -38,7 +38,8 @@ export function Tooltip({ content, children, className }: { content: string; chi role="tooltip" style={{ top: position.top, left: position.left, width: WIDTH }} > - {content} + {/* Matches the chart tooltips (tooltipClass in charts.tsx). */} + {content} ) : null} diff --git a/components/ui.tsx b/components/ui.tsx index 93c5857..3d79057 100644 --- a/components/ui.tsx +++ b/components/ui.tsx @@ -446,8 +446,8 @@ export function SegmentedLinks({ label, items }: { label: string; items: readonl } /** - * Sticky page header with the title, meta line, and filters. It bleeds to the main - * area's edges with negative margins that mirror the shell's `p-4 lg:p-6` padding. + * Sticky page header card with the title, meta line, and filters. It sticks at the + * sidebar card's top offset; the strip above it hides content scrolling through the gap. */ export function PageToolbar({ title, @@ -463,7 +463,7 @@ export function PageToolbar({ back?: { href: string; label: string }; }) { return ( -
+
{back ? ( diff --git a/lib/queries.ts b/lib/queries.ts index c552b43..563d9d6 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -1,7 +1,7 @@ // Every dashboard query, as SQL text only (lib/dashboard.ts runs them). To tune a widget, // edit its template below: the doc comment names the widget, and the SQL aliases are the // field names the page reads. When you add or rename an alias, update that query's row type -// (the run<{...}> call in lib/dashboard.ts) and its use in app/page.tsx. +// (the run<{...}> call in lib/dashboard.ts) and its use in app/(dashboard)/page.tsx. // // Rules every query keeps: // - Delivery is at least once, so the same event_id can be stored twice. Count events with diff --git a/public/overview.jpg b/public/overview.jpg index d4ea816..d40e0a4 100644 Binary files a/public/overview.jpg and b/public/overview.jpg differ