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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions app/console/page.tsx → app/(dashboard)/console/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
53 changes: 53 additions & 0 deletions app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen max-md:block">
<aside className="sticky top-3 m-3 flex h-[calc(100dvh-1.5rem)] w-60 shrink-0 flex-col gap-2 overflow-y-auto rounded-2xl bg-sidebar px-3 py-4 text-sidebar-foreground shadow-soft ring-1 ring-foreground/10 max-md:static max-md:m-0 max-md:h-auto max-md:w-auto max-md:rounded-none max-md:border-b max-md:border-sidebar-border max-md:pt-2.5 max-md:pb-1.5 max-md:shadow-none max-md:ring-0">
<div className="flex items-center gap-3 px-2 pt-1 pb-4 font-semibold tracking-tight max-md:hidden">
<Image alt="" className="h-8 w-auto" src={rawtreeLogo} />
<span className="grid leading-tight">
RawTree
<small className="text-2xs font-medium tracking-normal text-muted-foreground">Web analytics</small>
</span>
</div>
<SidebarNav />
<div className="mt-auto grid gap-3 max-md:mt-2">
<nav aria-label="Resources" className="grid gap-1.5 px-2 text-sm text-muted-foreground max-md:hidden">
{[
{ label: "RawTree Docs", href: "https://rawtree.com/docs" },
{ label: "GitHub", href: "https://github.com/rawtreedb/rawtree-web-analytics" },
].map((link) => (
<a className="inline-flex w-fit items-center gap-1 hover:text-foreground" href={link.href} key={link.label} rel="noopener noreferrer" target="_blank">
{link.label}
<IconExternalLink aria-hidden className="size-3" />
</a>
))}
</nav>
{access.mode === "env" ? null : (
<form action={signOut} className="grid gap-1 border-t border-sidebar-border px-2 pt-3 max-md:flex max-md:items-center max-md:justify-between">
<span className="truncate text-xs text-muted-foreground" title={access.label}>
{access.label}
</span>
<button className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground" type="submit">
<IconLogout aria-hidden className="size-4" />
Sign out
</button>
</form>
)}
</div>
</aside>
{/* pt-3 matches the sidebar's m-3, so the PageToolbar card lines up with the sidebar card. */}
<main className="@container min-w-0 flex-1 px-4 pt-3 pb-4 lg:px-6 lg:pb-6">{children}</main>
</div>
);
}
16 changes: 8 additions & 8 deletions app/page.tsx → app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,7 +23,7 @@ import {
StatGrid,
chartColors,
crawlerCategoryColors,
} from "../components/ui.tsx";
} from "../../components/ui.tsx";

export const dynamic = "force-dynamic";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
64 changes: 4 additions & 60 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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.";
Expand All @@ -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 (
<html lang="en">
<body>
{children}
</body>
</html>
);
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<div className="flex min-h-screen max-md:block">
<aside className="sticky top-0 flex h-screen w-60 shrink-0 flex-col gap-2 overflow-y-auto border-r border-sidebar-border bg-sidebar px-3 py-4 text-sidebar-foreground max-md:static max-md:h-auto max-md:w-auto max-md:border-r-0 max-md:border-b max-md:pt-2.5 max-md:pb-1.5">
<div className="flex items-center gap-3 px-2 pt-1 pb-4 font-semibold tracking-tight max-md:hidden">
<Image alt="" className="h-8 w-auto" src={rawtreeLogo} />
<span className="grid leading-tight">
RawTree
<small className="text-2xs font-medium tracking-normal text-muted-foreground">Web analytics</small>
</span>
</div>
<SidebarNav />
<div className="mt-auto grid gap-3 max-md:mt-2">
<nav aria-label="Resources" className="grid gap-1.5 px-2 text-sm text-muted-foreground max-md:hidden">
{[
{ label: "RawTree Docs", href: "https://rawtree.com/docs" },
{ label: "GitHub", href: "https://github.com/rawtreedb/rawtree-web-analytics" },
].map((link) => (
<a className="inline-flex w-fit items-center gap-1 hover:text-foreground" href={link.href} key={link.label} rel="noopener noreferrer" target="_blank">
{link.label}
<IconExternalLink aria-hidden className="size-3" />
</a>
))}
</nav>
{access.mode === "env" ? null : (
<form action={signOut} className="grid gap-1 border-t border-sidebar-border px-2 pt-3 max-md:flex max-md:items-center max-md:justify-between">
<span className="truncate text-xs text-muted-foreground" title={access.label}>
{access.label}
</span>
<button className="w-fit text-sm font-medium text-muted-foreground hover:text-foreground" type="submit">
Sign out
</button>
</form>
)}
</div>
</aside>
{/* PageToolbar mirrors this padding with negative margins to bleed to the edges; keep them in sync. */}
<main className="@container min-w-0 flex-1 p-4 lg:p-6">{children}</main>
</div>
</body>
<body>{children}</body>
</html>
);
}
Loading
Loading