add search, chat, and feedback analytics with dashboard

This commit is contained in:
miloschwartz
2026-09-28 14:03:29 -04:00
parent 9d1b9f3820
commit 6588fc870b
42 changed files with 5932 additions and 108 deletions
+7
View File
@@ -0,0 +1,7 @@
# Copy to .env.local.
# Postgres the Fossorial API writes docs analytics to.
DATABASE_URL=postgres://postgres:postgres@localhost:5432/api
# Docs site the page paths link to
DOCS_SITE_URL=https://docs.pangolin.net
+6
View File
@@ -0,0 +1,6 @@
/node_modules
/.next/
*.tsbuildinfo
next-env.d.ts
.env
.env*.local
+1
View File
@@ -0,0 +1 @@
24
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+20
View File
@@ -0,0 +1,20 @@
# Docs analytics dashboard
A small Next.js app for reviewing what readers do on the docs site: page 👍/👎, AI chats and their 👍/👎, and search-bar queries, including those that found nothing.
**Where the data comes from:** the docs site forwards events to the Fossorial API (`/api/v1/docs-analytics`), which stores them in its Postgres (`docs*` tables, migrations in the api repo). This app reads those tables directly with Drizzle and never writes.
**Retention:** the API deletes docs analytics older than 90 days (`src/controllers/docsAnalytics/retention.ts` in the api repo), so the dashboard's longest range is 90 days (`RETENTION_DAYS` in `src/lib/range.ts`). Change both together.
## Run it
```bash
cd analytics-dashboard
npm install
cp .env.example .env.local # set DATABASE_URL to the API's Postgres
npm run dev # http://127.0.0.1:3005
```
- Point `DATABASE_URL` at the same database the API uses (for local development, the api repo's `docker-compose.postgres.yml`). A read-only role is enough; `.env.example` has the grants.
- `DOCS_SITE_URL` is where page links go (defaults to https://docs.pangolin.net).
- There is **no login**. The dev server only listens on 127.0.0.1. Add auth before deploying it anywhere.
+10
View File
@@ -0,0 +1,10 @@
import { fileURLToPath } from 'node:url';
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
// this app lives inside the docs repo but is its own project (own lockfile)
turbopack: { root: fileURLToPath(new URL('.', import.meta.url)) },
};
export default config;
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "pangolin-docs-analytics-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -H 127.0.0.1 -p 3005",
"build": "next build",
"start": "next start -H 127.0.0.1 -p 3005",
"types:check": "next typegen && tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.45.2",
"next": "^16.3.5",
"pg": "^8.23.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@types/node": "^26.6.3",
"@types/pg": "^8.23.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"postcss": "^8.5.28",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
@@ -0,0 +1,91 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { connection } from 'next/server';
import { cn } from '@/lib/cn';
import { getThread } from '@/lib/queries';
import { formatDateTime } from '@/lib/range';
import { Card } from '@/components/ui';
import { MessageMarkdown } from '@/components/message-markdown';
const docsUrl = process.env.DOCS_SITE_URL ?? 'https://docs.pangolin.net';
function toolLabel(tool: { name: string; input?: unknown }) {
const input = (tool.input ?? {}) as Record<string, unknown>;
const arg = input.query ?? input.path;
return typeof arg === 'string' ? `${tool.name}: ${arg}` : tool.name;
}
export default async function ChatPage(props: PageProps<'/chats/[id]'>) {
await connection();
const { id } = await props.params;
const thread = await getThread(id);
if (!thread) notFound();
return (
<div className="flex flex-col gap-4">
<Link href="/#chats" className="text-sm text-fd-muted-foreground hover:text-fd-foreground">
← All chats
</Link>
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold">AI chat</h1>
<p className="text-sm text-fd-muted-foreground">
{formatDateTime(thread.createdAt)}
{thread.page && (
<>
{' · started on '}
<a href={`${docsUrl}${thread.page}`} target="_blank" rel="noreferrer" className="underline underline-offset-2">
{thread.page}
</a>
</>
)}
{thread.visitorId && <> · visitor {thread.visitorId.slice(0, 8)}</>}
</p>
</div>
<div className="flex flex-col gap-3">
{thread.messages.map((m) => (
<Card
key={m.id}
id={m.id}
className={cn(
'scroll-mt-4 target:ring-2 target:ring-fd-ring',
m.role === 'user' && 'bg-fd-secondary',
)}
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className={cn('text-sm font-medium', m.role === 'assistant' && 'text-fd-primary')}>
{m.role === 'user' ? 'User' : 'Pangolin AI'}
</p>
<div className="flex items-center gap-3 text-xs text-fd-muted-foreground">
{m.vote === 1 && <span className="rounded-full border px-2 py-0.5 text-fd-foreground">👍 Helpful</span>}
{m.vote === -1 && (
<span className="rounded-full border px-2 py-0.5 text-fd-foreground">👎 Not helpful</span>
)}
<span>{formatDateTime(m.createdAt)}</span>
</div>
</div>
{m.tools.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{m.tools.map((t, i) => (
<code key={i} className="max-w-full truncate rounded border bg-fd-background px-1.5 py-0.5 text-xs">
{toolLabel(t)}
</code>
))}
</div>
)}
{m.role === 'user' ? (
<p className="text-sm whitespace-pre-wrap break-words">{m.content}</p>
) : m.content ? (
<div className="prose text-sm max-w-none">
<MessageMarkdown text={m.content} />
</div>
) : (
<p className="text-sm text-fd-muted-foreground">(no text, the answer was stopped or failed)</p>
)}
</Card>
))}
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
/*
* Same palette and token names as the docs site (`--color-fd-*`), so the markup reads the
* same in both apps. Dark mode follows the OS.
*/
@theme {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--color-fd-background: #faf9f2;
--color-fd-foreground: #202020;
--color-fd-muted-foreground: #6b6a63;
--color-fd-popover: #faf9f2;
--color-fd-popover-foreground: #202020;
--color-fd-card: #f2f0e7;
--color-fd-border: #bbbbbb;
--color-fd-primary: #202020;
--color-fd-secondary: #f2f0e7;
--color-fd-accent: #e8e7e5;
--color-fd-ring: #bbbbbb;
}
@media (prefers-color-scheme: dark) {
:root {
--color-fd-background: #161614;
--color-fd-foreground: #ecebe6;
--color-fd-muted-foreground: #a3a199;
--color-fd-popover: #1b1a18;
--color-fd-popover-foreground: #ecebe6;
--color-fd-card: #1f1e1b;
--color-fd-border: #2d2c28;
--color-fd-primary: #f36117;
--color-fd-secondary: #1f1e1b;
--color-fd-accent: #2d2c28;
--color-fd-ring: #55534c;
color-scheme: dark;
}
}
@layer base {
*,
::before,
::after {
border-color: var(--color-fd-border);
}
body {
background-color: var(--color-fd-background);
color: var(--color-fd-foreground);
}
}
/* chat answers: typography plugin, tinted to the palette */
.prose {
--tw-prose-body: var(--color-fd-foreground);
--tw-prose-headings: var(--color-fd-foreground);
--tw-prose-links: var(--color-fd-foreground);
--tw-prose-bold: var(--color-fd-foreground);
--tw-prose-code: var(--color-fd-foreground);
--tw-prose-bullets: var(--color-fd-muted-foreground);
--tw-prose-counters: var(--color-fd-muted-foreground);
--tw-prose-pre-bg: var(--color-fd-background);
--tw-prose-pre-code: var(--color-fd-foreground);
--tw-prose-th-borders: var(--color-fd-border);
--tw-prose-td-borders: var(--color-fd-border);
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Link from 'next/link';
import './globals.css';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export const metadata: Metadata = {
title: 'Docs analytics',
robots: { index: false, follow: false },
};
const docsUrl = process.env.DOCS_SITE_URL ?? 'https://docs.pangolin.net';
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.variable}>
<body className="min-h-screen font-sans antialiased">
<header className="border-b">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<Link href="/" className="font-medium">
Pangolin Docs · Analytics
</Link>
<a href={docsUrl} className="text-sm text-fd-muted-foreground hover:text-fd-foreground">
View docs ↗
</a>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-6 md:py-8">{children}</main>
</body>
</html>
);
}
+435
View File
@@ -0,0 +1,435 @@
import Link from 'next/link';
import { connection } from 'next/server';
import { cn } from '@/lib/cn';
import {
getDownvotedResponses,
getQuestionActivity,
getSummary,
getThreads,
getTopPages,
getTopSearches,
type Range,
} from '@/lib/queries';
import {
formatDateTime,
formatDay,
oldestDay,
param,
presets,
resolveRange,
withParams,
type SearchParams,
} from '@/lib/range';
import { ActivityChart } from '@/components/activity-chart';
import { Card, CardTitle, Empty, percent, Stat, Votes } from '@/components/ui';
const PAGE_SIZE = 25;
const inputClass =
'rounded-md border bg-fd-background px-2 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring';
const buttonClass =
'rounded-md border bg-fd-secondary px-3 py-1.5 text-sm font-medium hover:bg-fd-accent';
/** answer snippets: drop the Markdown syntax that would show up as literal characters */
function plain(markdown: string) {
return markdown
.replace(/```[\s\S]*?```/g, ' [code] ')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*`#>]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
const docsUrl = process.env.DOCS_SITE_URL ?? 'https://docs.pangolin.net';
export default async function AnalyticsPage(props: PageProps<'/'>) {
await connection();
const params: SearchParams = await props.searchParams;
const range = resolveRange(params);
const q = param(params, 'q');
const feedbackParam = param(params, 'feedback');
const feedback = feedbackParam === 'up' || feedbackParam === 'down' ? feedbackParam : undefined;
const offset = Math.max(0, Number(param(params, 'offset')) || 0);
let data;
try {
data = await Promise.all([
getSummary(range),
getQuestionActivity(range),
getTopPages(range, 'up'),
getTopPages(range, 'down'),
getDownvotedResponses(range),
getThreads({ ...range, q, feedback, offset, limit: PAGE_SIZE }),
getTopSearches(range),
getTopSearches(range, { empty: true }),
]);
} catch (e) {
console.error('[analytics] dashboard', e);
return (
<Card>
<CardTitle>Analytics database unavailable</CardTitle>
<p className="text-sm text-fd-muted-foreground">
Check <code>DATABASE_URL</code> and the server logs. Error:{' '}
<code>{e instanceof Error ? e.message : String(e)}</code>
</p>
</Card>
);
}
const [summary, activity, topUp, topDown, downvoted, threads, topSearches, emptySearches] = data;
const pageVotes = summary.pages.up + summary.pages.down;
const responseVotes = summary.responses.up + summary.responses.down;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h1 className="text-2xl font-semibold">Docs analytics</h1>
<p className="text-sm text-fd-muted-foreground">{range.label}</p>
</div>
<RangeFilter params={params} range={range} activePreset={range.preset} />
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<Stat
label="Pages rated helpful"
value={percent(summary.pages.up, pageVotes)}
detail={<Votes up={summary.pages.up} down={summary.pages.down} />}
/>
<Stat label="AI chats" value={summary.threads.toLocaleString()} detail="conversations started" />
<Stat
label="Questions asked"
value={summary.questions.toLocaleString()}
detail={summary.threads > 0 ? `${(summary.questions / summary.threads).toFixed(1)} per chat` : undefined}
/>
<Stat
label="Searches"
value={summary.searches.total.toLocaleString()}
detail={
summary.searches.total > 0
? `${percent(summary.searches.empty, summary.searches.total)} found nothing`
: undefined
}
/>
<Stat
label="AI answers rated good"
value={percent(summary.responses.up, responseVotes)}
detail={<Votes up={summary.responses.up} down={summary.responses.down} />}
/>
</div>
<Card>
<CardTitle hint="per day, UTC">
AI questions asked
</CardTitle>
<ActivityChart points={activity} />
</Card>
<div className="grid gap-3 lg:grid-cols-2">
<PageTable title="Most upvoted pages" rows={topUp} empty="No upvotes in this range." />
<PageTable title="Most downvoted pages" rows={topDown} empty="No downvotes in this range." />
</div>
<div className="grid gap-3 lg:grid-cols-2">
<SearchTable
title="Top searches"
hint="opened = picked a result"
rows={topSearches}
empty="No searches in this range."
/>
<SearchTable
title="Searches with no results"
hint="gaps in the docs"
rows={emptySearches}
empty="Every search found something."
noResults
/>
</div>
<Card>
<CardTitle hint="most recent first">Downvoted AI answers</CardTitle>
{downvoted.length === 0 ? (
<Empty>No downvoted answers in this range.</Empty>
) : (
<ul className="divide-y">
{downvoted.map((r) => (
<li key={r.messageId} className="py-3 first:pt-0 last:pb-0">
<Link
href={`/chats/${r.threadId}#${r.messageId}`}
className="group block min-w-0"
>
<p className="text-sm font-medium group-hover:underline line-clamp-2">{r.question}</p>
<p className="mt-1 text-sm text-fd-muted-foreground line-clamp-2">{plain(r.answer)}</p>
<p className="mt-1 text-xs text-fd-muted-foreground">{formatDateTime(r.votedAt)}</p>
</Link>
</li>
))}
</ul>
)}
</Card>
<Card id="chats">
<CardTitle hint="newest first">AI chats</CardTitle>
<form className="mb-3 flex flex-wrap items-center gap-2" action="/#chats">
{/* keep the time range when searching */}
{(['range', 'from', 'to'] as const).map((key) => {
const value = param(params, key);
return value ? <input key={key} type="hidden" name={key} value={value} /> : null;
})}
<input
name="q"
defaultValue={q}
placeholder="Search questions…"
aria-label="Search questions"
className={cn(inputClass, 'min-w-0 flex-1 basis-48')}
/>
<select name="feedback" defaultValue={feedback ?? ''} aria-label="Feedback" className={inputClass}>
<option value="">Any feedback</option>
<option value="down">Has 👎</option>
<option value="up">Has 👍</option>
</select>
<button type="submit" className={buttonClass}>
Filter
</button>
{(q || feedback) && (
<Link
href={`/${withParams(params, { q: undefined, feedback: undefined, offset: undefined })}#chats`}
className="text-sm text-fd-muted-foreground hover:text-fd-foreground"
>
Reset
</Link>
)}
</form>
{threads.threads.length === 0 ? (
<Empty>No chats match.</Empty>
) : (
<div className="-mx-4 overflow-x-auto px-4">
<table className="w-full min-w-[40rem] text-sm">
<thead>
<tr className="text-xs text-fd-muted-foreground">
<th className="pb-2 text-start font-normal">First question</th>
<th className="pb-2 text-start font-normal">Started on</th>
<th className="pb-2 text-end font-normal">Questions</th>
<th className="pb-2 text-end font-normal">Feedback</th>
<th className="pb-2 text-end font-normal">When</th>
</tr>
</thead>
<tbody>
{threads.threads.map((t) => (
<tr key={t.id} className="border-t align-top">
<td className="py-2 pe-4">
<Link href={`/chats/${t.id}`} className="line-clamp-2 hover:underline">
{t.firstQuestion}
</Link>
</td>
<td className="max-w-[14rem] py-2 pe-4 text-fd-muted-foreground">
<span className="block truncate" title={t.page ?? undefined}>
{t.page ?? '—'}
</span>
</td>
<td className="py-2 text-end tabular-nums">{t.questions}</td>
<td className="py-2 ps-4 text-end">
{t.up + t.down > 0 ? <Votes up={t.up} down={t.down} /> : <span className="text-fd-muted-foreground">—</span>}
</td>
<td className="py-2 ps-4 text-end whitespace-nowrap text-fd-muted-foreground">
{formatDateTime(t.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(offset > 0 || threads.hasMore) && (
<nav className="mt-3 flex items-center justify-between text-sm">
{offset > 0 ? (
<Link
href={`/${withParams(params, { offset: offset - PAGE_SIZE > 0 ? String(offset - PAGE_SIZE) : undefined })}#chats`}
className={buttonClass}
>
← Newer
</Link>
) : (
<span />
)}
<span className="text-xs text-fd-muted-foreground">
{offset + 1}–{offset + threads.threads.length}
</span>
{threads.hasMore ? (
<Link
href={`/${withParams(params, { offset: String(offset + PAGE_SIZE) })}#chats`}
className={buttonClass}
>
Older →
</Link>
) : (
<span />
)}
</nav>
)}
</Card>
</div>
);
}
function RangeFilter({
params,
range,
activePreset,
}: {
params: SearchParams;
range: Range;
activePreset: string | null;
}) {
const reset = { from: undefined, to: undefined, offset: undefined };
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex flex-wrap gap-1 rounded-lg border bg-fd-secondary p-1" role="group" aria-label="Time range">
{presets.map((p) => (
<Link
key={p.id}
href={`/${withParams(params, { ...reset, range: p.id === '30d' ? undefined : p.id })}`}
aria-current={activePreset === p.id ? 'true' : undefined}
className={cn(
'rounded-md px-2.5 py-1 text-sm text-fd-muted-foreground hover:text-fd-foreground',
activePreset === p.id && 'bg-fd-background text-fd-foreground shadow-sm',
)}
>
{p.label}
</Link>
))}
</div>
<form className="flex flex-wrap items-center gap-2" action="/">
{(['q', 'feedback'] as const).map((key) => {
const value = param(params, key);
return value ? <input key={key} type="hidden" name={key} value={value} /> : null;
})}
<input
type="date"
name="from"
aria-label="From"
min={oldestDay()}
max={formatDay(Date.now())}
defaultValue={activePreset ? undefined : formatDay(range.from)}
className={inputClass}
/>
<span className="text-sm text-fd-muted-foreground">to</span>
<input
type="date"
name="to"
aria-label="To"
min={oldestDay()}
max={formatDay(Date.now())}
defaultValue={activePreset ? undefined : formatDay(range.to - 1)}
className={inputClass}
/>
<button type="submit" className={buttonClass}>
Apply
</button>
</form>
</div>
);
}
function PageTable({
title,
rows,
empty,
}: {
title: string;
rows: { page: string; up: number; down: number }[];
empty: string;
}) {
return (
<Card>
<CardTitle>{title}</CardTitle>
{rows.length === 0 ? (
<Empty>{empty}</Empty>
) : (
<table className="w-full table-fixed text-sm">
<thead>
<tr className="text-xs text-fd-muted-foreground">
<th className="pb-2 text-start font-normal">Page</th>
<th className="w-28 pb-2 text-end font-normal">Votes</th>
<th className="w-16 pb-2 text-end font-normal">Helpful</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.page} className="border-t">
<td className="py-2 pe-3">
<a
href={`${docsUrl}${r.page}`}
target="_blank"
rel="noreferrer"
className="block truncate hover:underline"
title={r.page}
>
{r.page}
</a>
</td>
<td className="py-2 text-end">
<Votes up={r.up} down={r.down} />
</td>
<td className="py-2 text-end tabular-nums">{percent(r.up, r.up + r.down)}</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
);
}
function SearchTable({
title,
hint,
rows,
empty,
noResults = false,
}: {
title: string;
hint: string;
rows: { query: string; count: number; clicks: number; results: number; lastSearched: number }[];
empty: string;
noResults?: boolean;
}) {
return (
<Card>
<CardTitle hint={hint}>{title}</CardTitle>
{rows.length === 0 ? (
<Empty>{empty}</Empty>
) : (
<table className="w-full table-fixed text-sm">
<thead>
<tr className="text-xs text-fd-muted-foreground">
<th className="pb-2 text-start font-normal">Query</th>
<th className="w-20 pb-2 text-end font-normal">Searches</th>
<th className="w-24 pb-2 text-end font-normal">{noResults ? 'Last' : 'Opened'}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.query} className="border-t">
<td className="py-2 pe-3">
<span className="block truncate" title={r.query}>
{r.query}
</span>
{!noResults && r.results === 0 && (
<span className="block text-xs text-fd-muted-foreground">no results</span>
)}
</td>
<td className="py-2 text-end tabular-nums">{r.count}</td>
<td className="py-2 text-end tabular-nums text-fd-muted-foreground">
{noResults ? formatDay(r.lastSearched) : percent(r.clicks, r.count)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
);
}
@@ -0,0 +1,82 @@
import { cn } from '@/lib/cn';
import { formatDay } from '@/lib/range';
import { Empty } from './ui';
/**
* Single-series bar chart with a hover/focus tooltip per bar (pure CSS, no client JS)
* and a table view for screen readers and exact numbers.
*/
export function ActivityChart({ points }: { points: { start: number; count: number }[] }) {
if (points.length === 0) return <Empty>No questions in this range.</Empty>;
const max = Math.max(1, ...points.map((p) => p.count));
// first, middle and last bucket; with only 1–2 buckets these overlap
const ticks = [...new Set([points[0], points[Math.floor((points.length - 1) / 2)], points.at(-1)!])];
return (
<div>
<div className="relative">
{/* recessive gridlines: max and half */}
<div className="pointer-events-none absolute inset-x-0 top-0 border-t border-dashed border-fd-border" />
<div className="pointer-events-none absolute inset-x-0 top-1/2 border-t border-dashed border-fd-border" />
<span className="absolute -top-2 end-0 bg-fd-card ps-1 text-[11px] text-fd-muted-foreground tabular-nums">
{max}
</span>
<div className="flex h-40 items-end justify-center gap-[2px] border-b border-fd-border pe-6" aria-hidden>
{points.map((p, i) => (
<div
key={p.start}
tabIndex={0}
className="group relative flex h-full min-w-0 max-w-12 flex-1 items-end outline-none"
>
<div
className="w-full rounded-t-[4px] bg-fd-primary/80 transition-colors group-hover:bg-fd-primary group-focus-visible:bg-fd-primary"
style={{ height: p.count === 0 ? 0 : `max(2px, ${(p.count / max) * 100}%)` }}
/>
{/* tooltips at the edges anchor inward so they never overflow the page */}
<div
className={cn(
'pointer-events-none absolute bottom-full z-10 mb-1 hidden whitespace-nowrap rounded-md border bg-fd-popover px-2 py-1 text-xs text-fd-popover-foreground shadow-md group-hover:block group-focus-visible:block',
i < points.length / 4
? 'left-0'
: i >= (points.length * 3) / 4
? 'right-0'
: 'left-1/2 -translate-x-1/2',
)}
>
<span className="text-fd-muted-foreground">{formatDay(p.start)}</span>{' '}
<strong className="tabular-nums">{p.count}</strong>
</div>
</div>
))}
</div>
</div>
<div className="mt-1 flex justify-between pe-6 text-[11px] text-fd-muted-foreground tabular-nums">
{ticks.map((p) => (
<span key={p.start}>{formatDay(p.start)}</span>
))}
</div>
<details className="mt-3 text-xs">
<summary className="cursor-pointer text-fd-muted-foreground">Show as table</summary>
<table className="mt-2 w-full max-w-xs tabular-nums">
<thead>
<tr className="text-start text-fd-muted-foreground">
<th className="py-1 text-start font-normal">Day</th>
<th className="py-1 text-end font-normal">Questions</th>
</tr>
</thead>
<tbody>
{points.map((p) => (
<tr key={p.start} className="border-t">
<td className="py-1">{formatDay(p.start)}</td>
<td className="py-1 text-end">{p.count}</td>
</tr>
))}
</tbody>
</table>
</details>
</div>
);
}
@@ -0,0 +1,20 @@
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
const docsUrl = process.env.DOCS_SITE_URL ?? 'https://docs.pangolin.net';
/** assistant answers are Markdown; relative links point at the docs site, in a new tab */
export function MessageMarkdown({ text }: { text: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _, href, ...props }) => (
<a {...props} href={href?.startsWith('/') ? `${docsUrl}${href}` : href} target="_blank" rel="noreferrer" />
),
}}
>
{text}
</Markdown>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/lib/cn';
export function Card({ className, ...props }: ComponentProps<'section'>) {
return <section className={cn('rounded-xl border bg-fd-card p-4 min-w-0', className)} {...props} />;
}
export function CardTitle({ children, hint }: { children: ReactNode; hint?: ReactNode }) {
return (
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h2 className="text-sm font-medium">{children}</h2>
{hint && <p className="text-xs text-fd-muted-foreground">{hint}</p>}
</div>
);
}
export function Stat({ label, value, detail }: { label: string; value: ReactNode; detail?: ReactNode }) {
return (
<Card>
<p className="text-xs text-fd-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
{detail && <p className="mt-1 text-xs text-fd-muted-foreground tabular-nums">{detail}</p>}
</Card>
);
}
export function Empty({ children }: { children: ReactNode }) {
return <p className="py-6 text-center text-sm text-fd-muted-foreground">{children}</p>;
}
/** 👍 / 👎 counts as text + icon, never color alone */
export function Votes({ up, down }: { up: number; down: number }) {
return (
<span className="inline-flex gap-3 tabular-nums whitespace-nowrap">
<span title="Thumbs up">👍 {up}</span>
<span title="Thumbs down">👎 {down}</span>
</span>
);
}
export function percent(part: number, total: number) {
return total === 0 ? '—' : `${Math.round((part / total) * 100)}%`;
}
+22
View File
@@ -0,0 +1,22 @@
import 'server-only';
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
export type Db = NodePgDatabase<typeof schema>;
const globalForDb = globalThis as unknown as { __analyticsDb?: Db };
/** direct connection to the Fossorial API's Postgres (`DATABASE_URL`), read-only use */
export function getDb(): Db {
if (globalForDb.__analyticsDb) return globalForDb.__analyticsDb;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is not set (see .env.example)');
// one pool across dev hot reloads
globalForDb.__analyticsDb = drizzle(new Pool({ connectionString, max: 5 }), { schema });
return globalForDb.__analyticsDb;
}
export * from './schema';
+79
View File
@@ -0,0 +1,79 @@
/**
* Read-only copy of the docs analytics tables from the Fossorial API
* (api repo: src/services/db/schema.ts, `docs*` tables). The API owns these tables and
* their migrations; keep this file in sync when they change. This app never writes.
*/
import { index, integer, jsonb, pgTable, primaryKey, text, timestamp, varchar } from 'drizzle-orm/pg-core';
export const docsPageVoteTable = pgTable(
'docsPageVote',
{
page: text('page').notNull(),
visitorId: varchar('visitor_id', { length: 64 }).notNull(),
vote: integer('vote').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.page, t.visitorId] }), index('docsPageVote_updated_at_idx').on(t.updatedAt)],
);
export const docsChatThreadTable = pgTable(
'docsChatThread',
{
id: varchar('id', { length: 64 }).primaryKey(),
visitorId: varchar('visitor_id', { length: 64 }),
page: text('page'),
firstQuestion: text('first_question').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('docsChatThread_created_at_idx').on(t.createdAt)],
);
export const docsChatMessageTable = pgTable(
'docsChatMessage',
{
id: varchar('id', { length: 64 }).primaryKey(),
threadId: varchar('thread_id', { length: 64 })
.notNull()
.references(() => docsChatThreadTable.id, { onDelete: 'cascade' }),
role: varchar('role', { length: 16 }).$type<'user' | 'assistant'>().notNull(),
content: text('content').notNull(),
tools: jsonb('tools').$type<{ name: string; input?: unknown }[]>(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('docsChatMessage_thread_idx').on(t.threadId, t.createdAt),
index('docsChatMessage_created_at_idx').on(t.createdAt),
],
);
export const docsMessageVoteTable = pgTable(
'docsMessageVote',
{
messageId: varchar('message_id', { length: 64 })
.primaryKey()
.references(() => docsChatMessageTable.id, { onDelete: 'cascade' }),
threadId: varchar('thread_id', { length: 64 }).notNull(),
visitorId: varchar('visitor_id', { length: 64 }),
vote: integer('vote').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('docsMessageVote_updated_at_idx').on(t.updatedAt)],
);
export const docsSearchQueryTable = pgTable(
'docsSearchQuery',
{
id: varchar('id', { length: 64 }).primaryKey(),
visitorId: varchar('visitor_id', { length: 64 }),
query: varchar('query', { length: 256 }).notNull(),
results: integer('results').notNull(),
clickedUrl: text('clicked_url'),
page: text('page'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('docsSearchQuery_created_at_idx').on(t.createdAt)],
);
+3
View File
@@ -0,0 +1,3 @@
export function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
+244
View File
@@ -0,0 +1,244 @@
import 'server-only';
import { and, asc, count, desc, eq, exists, gte, ilike, lt, max, min, sql, type AnyColumn } from 'drizzle-orm';
import {
docsChatMessageTable as message,
docsChatThreadTable as thread,
docsMessageVoteTable as messageVote,
docsPageVoteTable as pageVote,
docsSearchQueryTable as search,
getDb,
} from '@/db';
/** epoch ms; `to` is exclusive */
export interface Range {
from: number;
to: number;
}
const DAY = 86_400_000;
function inRange(column: AnyColumn, { from, to }: Range) {
return and(gte(column, new Date(from)), lt(column, new Date(to)));
}
const upvotes = (vote: AnyColumn) => sql<number>`coalesce(sum(case when ${vote} = 1 then 1 else 0 end), 0)::int`;
const downvotes = (vote: AnyColumn) => sql<number>`coalesce(sum(case when ${vote} = -1 then 1 else 0 end), 0)::int`;
export async function getSummary(range: Range) {
const db = getDb();
const [[pages], [responses], [threads], [questions], [searches]] = await Promise.all([
db
.select({ up: upvotes(pageVote.vote), down: downvotes(pageVote.vote) })
.from(pageVote)
.where(inRange(pageVote.updatedAt, range)),
db
.select({ up: upvotes(messageVote.vote), down: downvotes(messageVote.vote) })
.from(messageVote)
.where(inRange(messageVote.updatedAt, range)),
db.select({ n: count() }).from(thread).where(inRange(thread.createdAt, range)),
db
.select({ n: count() })
.from(message)
.where(and(eq(message.role, 'user'), inRange(message.createdAt, range))),
db
.select({
n: count(),
empty: sql<number>`coalesce(sum(case when ${search.results} = 0 then 1 else 0 end), 0)::int`,
})
.from(search)
.where(inRange(search.createdAt, range)),
]);
return {
pages,
responses,
threads: threads.n,
questions: questions.n,
searches: { total: searches.n, empty: searches.empty },
};
}
/** start of the UTC day */
function dayStart(t: number) {
return t - (((t % DAY) + DAY) % DAY);
}
/** questions asked per UTC day, with empty days filled in */
export async function getQuestionActivity(range: Range) {
const db = getDb();
const userInRange = and(eq(message.role, 'user'), inRange(message.createdAt, range));
const [bounds] = await db.select({ first: min(message.createdAt) }).from(message).where(userInRange);
if (!bounds?.first) return [];
// epoch ms of the day; `at time zone 'UTC'` makes day boundaries UTC midnight
const dayOf = sql<number>`(extract(epoch from date_trunc('day', ${message.createdAt} at time zone 'UTC')) * 1000)::float8`;
const rows = await db.select({ day: dayOf, n: count() }).from(message).where(userInRange).groupBy(dayOf);
const counts = new Map(rows.map((r) => [Number(r.day), r.n]));
const points: { start: number; count: number }[] = [];
for (let d = dayStart(range.from); d <= dayStart(Math.min(range.to, Date.now())); d += DAY) {
points.push({ start: d, count: counts.get(d) ?? 0 });
}
return points;
}
export async function getTopPages(range: Range, by: 'up' | 'down', limit = 15) {
const up = upvotes(pageVote.vote);
const down = downvotes(pageVote.vote);
return getDb()
.select({ page: pageVote.page, up, down })
.from(pageVote)
.where(inRange(pageVote.updatedAt, range))
.groupBy(pageVote.page)
.having(sql`${by === 'up' ? up : down} > 0`)
.orderBy(desc(by === 'up' ? up : down), asc(by === 'up' ? down : up), asc(pageVote.page))
.limit(limit);
}
/** `ilike` pattern matching `q` literally (escapes `%`, `_` and `\`) */
function containsPattern(q: string) {
return `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
}
export interface ThreadFilter extends Range {
q?: string;
feedback?: 'up' | 'down';
offset?: number;
limit?: number;
}
/** the outer thread's id, table-qualified for use inside correlated subqueries */
const threadId = sql`${sql.identifier('docsChatThread')}.${sql.identifier('id')}`;
export async function getThreads({ q, feedback, offset = 0, limit = 25, ...range }: ThreadFilter) {
const db = getDb();
const filters = [inRange(thread.createdAt, range)];
if (q) {
filters.push(
exists(
db
.select({ one: sql`1` })
.from(message)
.where(
and(eq(message.threadId, thread.id), eq(message.role, 'user'), ilike(message.content, containsPattern(q))),
),
),
);
}
if (feedback) {
filters.push(
exists(
db
.select({ one: sql`1` })
.from(messageVote)
.where(and(eq(messageVote.threadId, thread.id), eq(messageVote.vote, feedback === 'up' ? 1 : -1))),
),
);
}
// fetch one extra row to know whether there is a next page
const rows = await db
.select({
id: thread.id,
firstQuestion: thread.firstQuestion,
page: thread.page,
createdAt: thread.createdAt,
// Drizzle leaves columns unqualified in a single-table select, so these correlated
// subqueries alias the inner table and name the outer one explicitly
questions: sql<number>`(select count(*)::int from ${message} m where m.thread_id = ${threadId} and m.role = 'user')`,
up: sql<number>`(select count(*)::int from ${messageVote} v where v.thread_id = ${threadId} and v.vote = 1)`,
down: sql<number>`(select count(*)::int from ${messageVote} v where v.thread_id = ${threadId} and v.vote = -1)`,
})
.from(thread)
.where(and(...filters))
.orderBy(desc(thread.createdAt), asc(thread.id))
.limit(limit + 1)
.offset(offset);
return {
hasMore: rows.length > limit,
threads: rows.slice(0, limit).map((r) => ({ ...r, createdAt: r.createdAt.getTime() })),
};
}
/** most recent thumbs-down answers with the question they answered */
export async function getDownvotedResponses(range: Range, limit = 10) {
const rows = await getDb()
.select({
threadId: messageVote.threadId,
messageId: messageVote.messageId,
votedAt: messageVote.updatedAt,
answer: message.content,
// the latest question asked before this answer (`u` is the inner copy of the table)
question: sql<string | null>`(select u.content from ${message} u where u.thread_id = ${message.threadId} and u.role = 'user' and u.created_at <= ${message.createdAt} order by u.created_at desc limit 1)`,
})
.from(messageVote)
.innerJoin(message, eq(message.id, messageVote.messageId))
.where(and(eq(messageVote.vote, -1), inRange(messageVote.updatedAt, range)))
.orderBy(desc(messageVote.updatedAt))
.limit(limit);
return rows.map((r) => ({ ...r, question: r.question ?? '', votedAt: r.votedAt.getTime() }));
}
export async function getThread(id: string) {
const db = getDb();
const [row] = await db.select().from(thread).where(eq(thread.id, id));
if (!row) return null;
const messages = await db
.select({
id: message.id,
role: message.role,
content: message.content,
tools: message.tools,
createdAt: message.createdAt,
vote: messageVote.vote,
})
.from(message)
.leftJoin(messageVote, eq(messageVote.messageId, message.id))
.where(eq(message.threadId, id))
// same-instant ties: user before assistant
.orderBy(asc(message.createdAt), desc(message.role));
return {
id: row.id,
page: row.page,
visitorId: row.visitorId,
createdAt: row.createdAt.getTime(),
messages: messages.map((m) => ({
...m,
tools: m.tools ?? [],
createdAt: m.createdAt.getTime(),
vote: m.vote ?? 0,
})),
};
}
/** searches grouped case-insensitively; `empty` limits them to ones that found nothing */
export async function getTopSearches(range: Range, { empty = false, limit = 15 } = {}) {
const filters = [inRange(search.createdAt, range)];
if (empty) filters.push(eq(search.results, 0));
const rows = await getDb()
.select({
query: max(search.query),
count: count(),
clicks: sql<number>`coalesce(sum(case when ${search.clickedUrl} is not null then 1 else 0 end), 0)::int`,
results: max(search.results),
lastSearched: max(search.createdAt),
})
.from(search)
.where(and(...filters))
.groupBy(sql`lower(${search.query})`)
.orderBy(desc(count()), desc(max(search.createdAt)))
.limit(limit);
return rows.map((r) => ({
query: r.query ?? '',
count: r.count,
clicks: r.clicks,
results: r.results ?? 0,
lastSearched: r.lastSearched?.getTime() ?? 0,
}));
}
+91
View File
@@ -0,0 +1,91 @@
const DAY = 86_400_000;
/**
* The Fossorial API deletes docs analytics older than this (api repo,
* src/controllers/docsAnalytics/retention.ts), so no range reaches further back.
*/
export const RETENTION_DAYS = 90;
export const presets = [
{ id: '24h', label: '24 hours', ms: DAY },
{ id: '7d', label: '7 days', ms: 7 * DAY },
{ id: '30d', label: '30 days', ms: 30 * DAY },
{ id: '90d', label: '90 days', ms: RETENTION_DAYS * DAY },
] as const;
/** first day that can still have data, as `YYYY-MM-DD` (for date inputs) */
export function oldestDay(now = Date.now()) {
return formatDay(now - RETENTION_DAYS * DAY);
}
export type SearchParams = Record<string, string | string[] | undefined>;
export function param(params: SearchParams, key: string) {
const value = params[key];
return (Array.isArray(value) ? value[0] : value)?.trim() || undefined;
}
/** `YYYY-MM-DD` as UTC midnight */
function parseDay(value: string | undefined) {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const t = Date.parse(`${value}T00:00:00Z`);
return Number.isNaN(t) ? undefined : t;
}
export function formatDay(t: number) {
return new Date(t).toISOString().slice(0, 10);
}
/**
* `?range=7d` picks a preset (default 30 days); `?from=2026-01-01&to=2026-01-31` is a
* custom range in UTC with both days included, clamped to the retention window.
*/
export function resolveRange(params: SearchParams, now = Date.now()) {
const from = parseDay(param(params, 'from'));
const to = parseDay(param(params, 'to'));
if (from !== undefined || to !== undefined) {
const floor = now - RETENTION_DAYS * DAY;
const end = to !== undefined ? to + DAY : now + 1;
const start = Math.max(Math.min(from ?? floor, end), floor);
return {
preset: null,
from: start,
to: Math.max(start, end),
label: `${formatDay(start)} to ${to !== undefined ? formatDay(to) : 'now'}`,
};
}
const preset = presets.find((p) => p.id === param(params, 'range')) ?? presets[2];
return {
preset: preset.id,
from: now - preset.ms,
to: now + 1,
label: `Last ${preset.label}`,
};
}
/** a query string with some keys replaced; `undefined` drops a key */
export function withParams(params: SearchParams, changes: Record<string, string | undefined>) {
const next = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const v = Array.isArray(value) ? value[0] : value;
if (v && !(key in changes)) next.set(key, v);
}
for (const [key, value] of Object.entries(changes)) if (value) next.set(key, value);
const qs = next.toString();
return qs ? `?${qs}` : '?';
}
const dateTime = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC',
});
export function formatDateTime(t: number) {
return `${dateTime.format(t)} UTC`;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./src/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}