feat(admin): add timeframe-aware overview visits chart #16

Merged
eleith merged 3 commits from feat/overview-visits-chart into main 2026-11-10 18:07:44 +00:00
26 changed files with 1348 additions and 117 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@goto/web': minor
'@goto/worker': minor
'@goto/cli': minor
---
Add an overview visits chart and retention-aware page-wide timeframes using prepared daily analytics. Headline and domain visit counts follow the selected UTC calendar window, with automatic daily, Monday-week or calendar-month chart grouping. Keep domain series out of the browser payload and hide analytics cards, controls and columns when collection is disabled. Domain and link pages remain unchanged.
+15 -13
View File
@@ -3,27 +3,29 @@ FROM node:26.5.0-alpine AS base
WORKDIR /m
ENV CI=true \
HOME=/home/app \
PNPM_HOME=/home/app/.pnpm \
XDG_DATA_HOME=/home/app/.local/share \
XDG_CACHE_HOME=/home/app/.cache \
XDG_CONFIG_HOME=/home/app/.config
HOME=/root
RUN npm install -g pnpm@11.9.0 \
&& mkdir -p /home/app/store \
&& printf 'store-dir=/home/app/store\n' > /home/app/.npmrc
&& mkdir -p /pnpm/store \
&& printf 'store-dir=/pnpm/store\n' > /root/.npmrc
FROM base AS dev
ARG LOCAL_UID=1000
ARG LOCAL_GID=1000
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile
ENV HOME=/home/app \
PNPM_HOME=/home/app/.pnpm \
XDG_DATA_HOME=/home/app/.local/share \
XDG_CACHE_HOME=/home/app/.cache \
XDG_CONFIG_HOME=/home/app/.config
# Only generated output and caches are writable; source binds stay read-only.
RUN mkdir -p /m/apps/web/config /m/apps/web/data /m/packages/config/dist /m/packages/analytics/dist /m/packages/db/dist /m/apps/web/.svelte-kit \
RUN mkdir -p /home/app/store /m/apps/web/config /m/apps/web/data /m/packages/config/dist /m/packages/analytics/dist /m/packages/db/dist /m/apps/web/.svelte-kit \
'/m/apps/web/node_modules/$app' \
/m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp \
&& printf 'store-dir=/home/app/store\n' > /home/app/.npmrc \
&& chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/apps/web/data /m/packages/config/dist /m/packages/analytics/dist /m/packages/db/dist \
/m/apps/web/.svelte-kit '/m/apps/web/node_modules/$app' \
/m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp
@@ -35,14 +37,14 @@ CMD ["sh", "-c", "pnpm -r --filter @goto/config --filter @goto/db... build && ex
FROM base AS builder
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile
RUN pnpm --filter "@goto/web..." build
FROM builder AS pruner
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store \
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm --config.hoist-workspace-packages=false --filter @goto/web --prod deploy --legacy /pruned \
&& mkdir -p /pruned/node_modules
+1 -1
View File
@@ -18,7 +18,7 @@ services:
NODE_ENV: development
PORT: 5173
ports:
- '127.0.0.1:5173:5173'
- '127.0.0.1:${GOTO_DEV_PORT:-5173}:5173'
volumes:
- ./config:/m/apps/web/config:ro
- ./data:/m/apps/web/data
+96 -1
View File
@@ -1,3 +1,4 @@
import type { Request } from '@playwright/test';
import { expect, test } from './support/test.ts';
test.use({ withWorker: true });
@@ -6,6 +7,11 @@ test('shared credentials protect overview, domain and link pages and sign-out re
page,
appServer
}) => {
const navigationWarnings: string[] = [];
page.on('console', (message) => {
const text = message.text();
if (text.includes('push_state_deprecated')) navigationWarnings.push(text);
});
await page.goto(`${appServer.origin}/admin`);
await expect(page).toHaveURL(/\/signin\?/);
await page.getByLabel('Username', { exact: true }).fill('example');
@@ -20,17 +26,82 @@ test('shared credentials protect overview, domain and link pages and sign-out re
.getByRole('region', { name: 'Valid visits', exact: true })
.locator('.admin-stat-value');
await expect(page.getByRole('region', { name: 'Domains', exact: true })).toContainText('2');
await expect(visitsValue).toHaveText('3');
await expect(visitsValue).toHaveText('2');
const retentionValue = page
.getByRole('region', { name: 'Retention', exact: true })
.locator('.admin-stat-value');
await expect(retentionValue).toHaveText('3 months');
await expect(page.locator('.admin-cards').getByRole('region')).toHaveCount(4);
const heading = page.locator('.admin-heading');
await expect(heading.locator('.admin-title > h1 + time')).toBeVisible();
const timeframe = heading.getByRole('group', { name: 'Timeframe', exact: true });
await expect(timeframe.getByRole('button')).toHaveText(['7 days', '30 days', '3 months']);
await expect(timeframe.getByRole('button', { name: '30 days', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
const trend = page.getByRole('region', { name: 'Visits over time', exact: true });
await expect(trend).toHaveAttribute('aria-busy', 'false');
const graph = trend.getByRole('img');
await expect(graph).toBeVisible();
await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 2\./);
const domainLink = page.getByRole('link', { name: 'go.example.org', exact: true });
const domainRow = page.getByRole('row').filter({ has: domainLink });
await expect(domainRow.getByRole('cell')).toHaveText(['6', '1']);
const selectionRequests: string[] = [];
const recordSelectionRequest = (request: Request) => {
const type = request.resourceType();
if (type === 'document' || type === 'fetch' || type === 'xhr') {
selectionRequests.push(request.url());
}
};
page.on('request', recordSelectionRequest);
await timeframe.getByRole('button', { name: '7 days', exact: true }).click();
await expect(page).toHaveURL(`${appServer.origin}/admin?period=7d`);
await expect(timeframe.getByRole('button', { name: '7 days', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(visitsValue).toHaveText('1');
await expect(domainRow.getByRole('cell')).toHaveText(['6', '0']);
await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 1\./);
await timeframe.getByRole('button', { name: '3 months', exact: true }).click();
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(visitsValue).toHaveText('3');
await expect(domainRow.getByRole('cell')).toHaveText(['6', '2']);
await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 3\./);
await page.goBack();
await expect(timeframe.getByRole('button', { name: '7 days', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(visitsValue).toHaveText('1');
await expect(domainRow.getByRole('cell')).toHaveText(['6', '0']);
await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 1\./);
await page.goForward();
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(visitsValue).toHaveText('3');
await expect(domainRow.getByRole('cell')).toHaveText(['6', '2']);
await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 3\./);
page.off('request', recordSelectionRequest);
expect(selectionRequests).toEqual([]);
await page.reload();
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(visitsValue).toHaveText('3');
await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 3\./);
expect(navigationWarnings).toEqual([]);
await domainLink.click();
await expect(page.getByRole('heading', { name: 'go.example.org', exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(0);
await expect(visitsValue).toHaveText('2');
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([
'Links',
@@ -94,3 +165,27 @@ test('shared credentials protect overview, domain and link pages and sign-out re
await expect(page).toHaveURL(/\/signin\?/);
await expect(page.getByLabel('Username', { exact: true })).toBeVisible();
});
test.describe('analytics disabled', () => {
test.use({ withWorker: false, retentionMonths: 0 });
test('overview remains inventory-only without analytics controls or columns', async ({
page,
appServer
}) => {
await page.goto(`${appServer.origin}/admin`);
await page.getByLabel('Username', { exact: true }).fill('example');
await page.getByLabel('Password', { exact: true }).fill('e2e-only-password');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Overview', exact: true })).toBeVisible();
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText(['Domains', 'Links']);
await expect(page.getByRole('group', { name: 'Timeframe', exact: true })).toHaveCount(0);
await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(
0
);
await expect(
page.getByRole('region', { name: 'Domain inventory', exact: true }).getByRole('columnheader')
).toHaveText(['Domain', 'Links']);
await expect(page.getByRole('status')).toHaveText('Analytics disabled.');
});
});
+13 -2
View File
@@ -26,6 +26,7 @@ process.env.PORT = String(address.port);
const configuration = {
site: { url: `http://localhost:${address.port}` },
auth: { credentials: { username: 'example', password: 'e2e-only-password' } },
analytics: { retention_months: Number(process.env.GOTO_E2E_RETENTION_MONTHS ?? '3') },
domains: {
'go.example.org': {
links: {
@@ -60,10 +61,20 @@ async function seedVisits() {
os: 'Other'
};
const visits = [
{ domain: 'go.example.org', path: '/docs', outcome: 'valid' },
{
domain: 'go.example.org',
path: '/docs',
outcome: 'valid',
visited_at: Date.now() - 14 * 86_400_000
},
{ domain: 'go.example.org', path: '/a/b', outcome: 'invalid' },
{ domain: 'go.example.org', path: '/', outcome: 'invalid' },
{ domain: 'go.example.org', path: '/a%2Fb', outcome: 'valid' },
{
domain: 'go.example.org',
path: '/a%2Fb',
outcome: 'valid',
visited_at: Date.now() - 45 * 86_400_000
},
{ domain: 'tiny.example.org', path: '/docs', outcome: 'valid' }
];
for (const visit of visits) await insertVisit(db, { ...common, ...visit });
+9 -3
View File
@@ -53,16 +53,22 @@ async function stopServer(child: ChildProcess): Promise<void> {
}
}
export const test = base.extend<{ appServer: AppServer; withWorker: boolean }>({
export const test = base.extend<{
appServer: AppServer;
withWorker: boolean;
retentionMonths: 0 | 3 | 9 | 12;
}>({
withWorker: [false, { option: true }],
appServer: async ({ withWorker }, use, testInfo) => {
retentionMonths: [3, { option: true }],
appServer: async ({ withWorker, retentionMonths }, use, testInfo) => {
const directory = process.env.GOTO_E2E_BUILD_DIR;
if (!directory) throw new Error('Missing E2E build directory');
const child = fork(new URL('../server.mjs', import.meta.url), [], {
env: {
...serverEnvironment(),
GOTO_E2E_BUILD_DIR: directory,
GOTO_E2E_WORKER: withWorker ? '1' : '0'
GOTO_E2E_WORKER: withWorker ? '1' : '0',
GOTO_E2E_RETENTION_MONTHS: String(retentionMonths)
},
execArgv: [],
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
+1
View File
@@ -28,6 +28,7 @@
"@goto/config": "workspace:*",
"@goto/db": "workspace:*",
"better-auth": "catalog:",
"chart.js": "catalog:",
"piscina": "catalog:"
},
"devDependencies": {
@@ -10,6 +10,7 @@
updatedAt = null,
backHref = '',
backLabel = 'Overview',
actions,
children
}: {
siteTitle: string;
@@ -17,6 +18,7 @@
updatedAt?: number | null;
backHref?: string;
backLabel?: string;
actions?: Snippet;
children: Snippet;
} = $props();
</script>
@@ -27,12 +29,15 @@
<header class="admin-heading">
{#if backHref}<nav aria-label="Breadcrumb"><a href={backHref}>{backLabel}</a></nav>{/if}
<div class="admin-title-row">
<h1>{title}</h1>
{#if updatedAt !== null}
<time class="admin-updated" datetime={new Date(updatedAt).toISOString()}
>{formatUpdated(updatedAt)}</time
>
{/if}
<div class="admin-title">
<h1>{title}</h1>
{#if updatedAt !== null}
<time class="admin-updated" datetime={new Date(updatedAt).toISOString()}
>{formatUpdated(updatedAt)}</time
>
{/if}
</div>
{@render actions?.()}
</div>
</header>
{@render children()}
@@ -0,0 +1,253 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Chart, ChartOptions } from 'chart.js';
import type { VisitDetails } from '@goto/analytics';
import { groupVisitDays, type VisitGranularity, type VisitWindow } from '@goto/analytics/daily';
import { formatCount } from './format.js';
let {
daily,
window: viewWindow,
granularity
}: {
daily: VisitDetails['daily'];
window: VisitWindow;
granularity: VisitGranularity;
} = $props();
let ready = $state(false);
let unavailable = $state(false);
let canvas = $state<HTMLCanvasElement>();
let palette: HTMLSpanElement;
let chart: Chart<'line', number[], string> | undefined;
const groupingLabels: Record<VisitGranularity, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly'
};
const dateFormat = new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC'
});
const monthFormat = new Intl.DateTimeFormat('en', {
month: 'short',
year: 'numeric',
timeZone: 'UTC'
});
let buckets = $derived(groupVisitDays(daily, viewWindow, granularity));
let total = $derived(buckets.reduce((sum, bucket) => sum + bucket.validVisits, 0));
let summary = $derived(
`Valid visits by ${granularity}. Total: ${formatCount(total)}. ${windowLabel(viewWindow)}.`
);
function windowLabel(window: VisitWindow): string {
const from = new Date(window.from);
const end = new Date(Date.parse(window.asOf) - 1);
return `${dateFormat.formatRange(from, end)} UTC`;
}
function bucketLabel(start: string, grouping: VisitGranularity): string {
return (grouping === 'month' ? monthFormat : dateFormat).format(new Date(start));
}
function bucketTitle(bucket: VisitDetails['daily'][number]): string {
const startTime = Math.max(Date.parse(bucket.start), Date.parse(viewWindow.from));
const endTime = Math.min(Date.parse(bucket.end), Date.parse(viewWindow.asOf)) - 1;
const from = new Date(startTime);
const end = new Date(endTime);
return `${dateFormat.formatRange(from, end)} UTC`;
}
function chartOptions(points: VisitDetails['daily']): ChartOptions<'line'> {
const colors = getComputedStyle(palette);
return {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: {
type: 'category',
ticks: { color: colors.outlineColor, maxTicksLimit: 8, maxRotation: 0 },
grid: { display: false },
border: { color: colors.borderTopColor }
},
y: {
type: 'linear',
beginAtZero: true,
ticks: {
color: colors.outlineColor,
precision: 0,
callback: (value) => formatCount(Number(value))
},
grid: { color: colors.borderTopColor },
border: { display: false }
}
},
plugins: {
tooltip: {
displayColors: false,
backgroundColor: colors.backgroundColor,
titleColor: colors.outlineColor,
bodyColor: colors.color,
footerColor: colors.outlineColor,
borderColor: colors.borderTopColor,
borderWidth: 1,
callbacks: {
title: (items) => {
const bucket = points[items[0]?.dataIndex ?? -1];
return bucket ? bucketTitle(bucket) : '';
},
label: (item) => `Valid visits: ${formatCount(item.parsed.y)}`,
footer: (items) => (points[items[0]?.dataIndex ?? -1]?.partial ? 'Partial period' : '')
}
}
}
};
}
function updateChart(points: VisitDetails['daily'], grouping: VisitGranularity) {
if (!chart) return;
const color = getComputedStyle(palette).color;
chart.data.labels = points.map((point) => bucketLabel(point.start, grouping));
chart.data.datasets = [
{
label: 'Valid visits',
data: points.map((point) => point.validVisits),
borderColor: color,
borderWidth: 2,
pointRadius: points.length === 1 ? 3 : 0,
pointHoverRadius: 4,
pointBackgroundColor: color,
fill: false,
tension: 0
}
];
chart.options = chartOptions(points);
chart.update('none');
}
$effect(() => {
const points = buckets;
const grouping = granularity;
if (ready) updateChart(points, grouping);
});
onMount(() => {
let disposed = false;
const themeObserver = new MutationObserver(() => updateChart(buckets, granularity));
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
async function mountChart() {
const element = canvas;
if (!element) {
unavailable = true;
return;
}
let chartClass: typeof Chart | undefined;
try {
const runtime = await import('chart.js');
if (disposed) return;
chartClass = runtime.Chart;
const context = element.getContext('2d');
if (!context) {
unavailable = true;
return;
}
runtime.Chart.register(
runtime.LineController,
runtime.LineElement,
runtime.PointElement,
runtime.CategoryScale,
runtime.LinearScale,
runtime.Tooltip
);
chart = new runtime.Chart<'line', number[], string>(context, {
type: 'line',
data: { labels: [], datasets: [] },
options: chartOptions(buckets)
});
updateChart(buckets, granularity);
ready = true;
} catch {
if (disposed) return;
chartClass?.getChart(element)?.destroy();
chart = undefined;
unavailable = true;
}
}
void mountChart();
return () => {
disposed = true;
themeObserver.disconnect();
chart?.destroy();
chart = undefined;
};
});
</script>
<section class="visits-chart" aria-label="Visits over time" aria-busy={!ready && !unavailable}>
<header>
<h2>Visits</h2>
<span class="grouping">{groupingLabels[granularity]}</span>
</header>
<span class="chart-palette" bind:this={palette} hidden aria-hidden="true"></span>
{#if unavailable}
<p role="status">Chart unavailable.</p>
{:else}
<div class="plot" role="img" aria-label={summary}>
<canvas bind:this={canvas} aria-hidden="true">{summary}</canvas>
</div>
{/if}
</section>
<style>
.visits-chart {
min-width: 0;
padding: var(--space-5);
background: var(--color-surface-card);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-card);
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
header h2 {
margin: 0;
}
.grouping {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.plot {
position: relative;
height: clamp(15rem, 32vw, 22rem);
min-width: 0;
}
.chart-palette {
color: var(--color-primary);
background-color: var(--color-surface-card);
border-color: var(--color-border-subtle);
outline-color: var(--color-text-muted);
}
p {
color: var(--color-text-muted);
}
</style>
+83 -4
View File
@@ -1,6 +1,7 @@
import { access, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import {
createVisitPeriod,
encodeVisitReports,
@@ -12,7 +13,7 @@ import type { GotoConfiguration } from '@goto/config';
import { openDb, runMigrations } from '@goto/db';
import { publishVisitReports } from '@goto/db/visit-reports';
import { expect, test, vi } from 'vitest';
import { readVisitSummary } from './visit-reports.js';
import { readOverviewDetails, readVisitSummary } from './visit-reports.js';
test('reads prepared output and hides snapshots from a different retention policy', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goto-page-reports-'));
@@ -46,22 +47,95 @@ test('reads prepared output and hides snapshots from a different retention polic
browsers: [{ family: 'Other', ...totals }],
operatingSystems: [{ family: 'Other', ...totals }]
};
const domain = 'go.example.test';
const domainScope = { kind: 'domain', domain } as const;
const domainDetails: VisitDetails = { ...details, scope: domainScope };
try {
const db = await openDb(configuration.database.path);
try {
await runMigrations(db);
await publishVisitReports(db, encodeVisitReports([{ summary, details }]));
expect(await readOverviewDetails(configuration, [domain])).toBeNull();
await publishVisitReports(
db,
encodeVisitReports([
{ summary, details },
{
summary: {
version: 1,
scope: domainScope,
period,
totals,
base: { validVisits: 0, invalidVisits: 0 },
links: []
},
details: domainDetails
}
])
);
} finally {
await db.destroy();
}
expect(await readVisitSummary(configuration, { kind: 'overview' })).toEqual(summary);
configuration.analytics.retention_months = 9;
const close = vi.spyOn(DatabaseSync.prototype, 'close');
try {
expect(await readOverviewDetails(configuration, [domain, 'new.example.test'])).toEqual({
overview: details,
domains: [{ domain, details: domainDetails }]
});
expect(close).toHaveBeenCalledTimes(1);
configuration.analytics.retention_months = 9;
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
expect(close).toHaveBeenCalledTimes(2);
} finally {
close.mockRestore();
}
expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test('invalid prepared output is unavailable, sanitized and closed', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goto-invalid-reports-'));
const configuration = {
database: {
path: join(directory, 'goto.sqlite'),
worker_path: join(directory, 'worker.sqlite')
},
analytics: { retention_months: 3 as const }
};
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const db = await openDb(configuration.database.path);
try {
await runMigrations(db);
await db
.insertInto('analytics_reports')
.values({
scope_kind: 'overview',
domain: null,
path: null,
summary_json: '{"private":"not-for-browser"}',
details_json: '{}'
})
.execute();
} finally {
await db.destroy();
}
const close = vi.spyOn(DatabaseSync.prototype, 'close');
try {
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
expect(close).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledExactlyOnceWith('Prepared visit reports unavailable');
} finally {
close.mockRestore();
}
} finally {
warning.mockRestore();
await rm(directory, { recursive: true, force: true });
}
});
test('enabled collection fails safely without creating missing storage', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goto-missing-reports-'));
const missing = join(directory, 'missing');
@@ -75,7 +149,11 @@ test('enabled collection fails safely without creating missing storage', async (
analytics: { retention_months: 3 as const }
};
expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull();
expect(warning).toHaveBeenCalledExactlyOnceWith('Prepared visit reports unavailable');
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
expect(warning.mock.calls).toEqual([
['Prepared visit reports unavailable'],
['Prepared visit reports unavailable']
]);
await expect(access(missing)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
warning.mockRestore();
@@ -91,6 +169,7 @@ test('disabled collection does not attempt to open missing storage', async () =>
analytics: { retention_months: 0 as const }
};
expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull();
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
expect(warning).not.toHaveBeenCalled();
} finally {
warning.mockRestore();
+25 -1
View File
@@ -1,11 +1,35 @@
import type { ReportScope, VisitSummary } from '@goto/analytics';
import type { GotoConfiguration } from '@goto/config';
import { openDb } from '@goto/db';
import { readVisitSummary as readPreparedSummary } from '@goto/db/visit-reports';
import {
readOverviewVisitDetails,
readVisitSummary as readPreparedSummary,
type OverviewVisitDetails
} from '@goto/db/visit-reports';
import type { Immutable } from './snapshot.js';
type ReportConfiguration = Immutable<Pick<GotoConfiguration, 'database' | 'analytics'>>;
export async function readOverviewDetails(
configuration: ReportConfiguration,
currentDomains: string[]
): Promise<OverviewVisitDetails | null> {
const months = configuration.analytics.retention_months;
if (months === 0) return null;
let db: Awaited<ReturnType<typeof openDb>> | undefined;
try {
db = await openDb(configuration.database.path, { readOnly: true });
const report = await readOverviewVisitDetails(db, currentDomains);
if (!report || report.overview.period.retentionMonths !== months) return null;
return report;
} catch {
warnUnavailable();
return null;
} finally {
await db?.destroy().catch(warnUnavailable);
}
}
export async function readVisitSummary(
configuration: ReportConfiguration,
scope: ReportScope
+25 -4
View File
@@ -14,13 +14,19 @@
.admin-title-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: var(--space-4) var(--space-5);
}
.admin-title {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.admin-updated {
margin-inline-start: auto;
text-align: end;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.admin-heading h1 {
font-size: var(--font-size-heading);
@@ -43,6 +49,20 @@
.admin-cards-summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.admin-cards-inventory {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-timeframes {
margin-inline-start: auto;
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.overview-timeframes button[aria-pressed='true'] {
font-weight: 600;
background: var(--color-surface-muted);
box-shadow: inset 0 0 0 var(--border-width) var(--color-border-strong);
}
.admin-stat,
.admin-info {
background: var(--color-surface-card);
@@ -149,7 +169,8 @@
}
}
@media (max-width: 400px) {
.admin-cards-summary {
.admin-cards-summary,
.admin-cards-inventory {
grid-template-columns: 1fr;
}
}
@@ -1,25 +1,45 @@
import { readVisitSummary } from '#lib/server/visit-reports.js';
import type { OverviewSummary } from '@goto/analytics';
import type { VisitDetails } from '@goto/analytics';
import {
availableVisitTimeframes,
sumValidVisits,
visitWindow,
type VisitTimeframe,
type VisitWindow
} from '@goto/analytics/daily';
import { readOverviewDetails } from '#lib/server/visit-reports.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
export const load = (async ({ locals }) => {
const snapshot = locals.snapshot;
const configuration = snapshot.configuration;
const retentionMonths = configuration.analytics.retention_months;
const collectionEnabled = retentionMonths !== 0;
let overview: OverviewSummary | null = null;
if (collectionEnabled) {
const summary = await readVisitSummary(configuration, { kind: 'overview' });
if (summary && 'domains' in summary) overview = summary;
}
const countsByDomain = new Map(overview?.domains.map((row) => [row.domain, row.totals]));
const domains = Object.entries(snapshot.domains).map(([domain, prepared]) => ({
domain,
linkCount: Object.keys(prepared.links).length,
counts: countsByDomain.get(domain) ?? null,
href: `/admin/domains/${encodeURIComponent(domain)}`
}));
const currentDomains = Object.keys(snapshot.domains);
const report = collectionEnabled
? await readOverviewDetails(configuration, currentDomains)
: null;
const timeframes = retentionMonths === 0 ? [] : availableVisitTimeframes(retentionMonths);
const overview = report?.overview ?? null;
const windows = overview
? timeframes.map((timeframe) => ({
timeframe,
window: visitWindow(overview.period, timeframe)
}))
: [];
const detailsByDomain = new Map(report?.domains.map(({ domain, details }) => [domain, details]));
const domains = Object.entries(snapshot.domains).map(([domain, prepared]) => {
const daily = detailsByDomain.get(domain)?.daily;
const visitsByTimeframe = countVisitWindows(daily, windows);
return {
domain,
linkCount: Object.keys(prepared.links).length,
visitsByTimeframe,
href: `/admin/domains/${encodeURIComponent(domain)}`
};
});
const asOf = overview ? Date.parse(overview.period.asOf) : null;
const visitsByTimeframe = countVisitWindows(overview?.daily, windows);
const chart = overview ? { daily: overview.daily, period: overview.period } : null;
return {
collectionEnabled,
available: overview !== null,
@@ -27,7 +47,21 @@ export const load: PageServerLoad = async ({ locals }) => {
asOf,
domainCount: domains.length,
linkCount: domains.reduce((sum, row) => sum + row.linkCount, 0),
totals: overview?.totals ?? null,
timeframes,
visitsByTimeframe,
chart,
domains
};
};
}) satisfies PageServerLoad;
function countVisitWindows(
daily: VisitDetails['daily'] | undefined,
windows: Array<{ timeframe: VisitTimeframe; window: VisitWindow }>
): Partial<Record<VisitTimeframe, number>> {
const counts: Partial<Record<VisitTimeframe, number>> = {};
if (!daily) return counts;
for (const { timeframe, window } of windows) {
counts[timeframe] = sumValidVisits(daily, window);
}
return counts;
}
@@ -1,20 +1,79 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { visitGranularity, visitWindow, type VisitTimeframe } from '@goto/analytics/daily';
import AdminPage from '#lib/components/admin/AdminPage.svelte';
import StatCard from '#lib/components/admin/StatCard.svelte';
import ReportNotice from '#lib/components/admin/ReportNotice.svelte';
import VisitsChart from '#lib/components/admin/VisitsChart.svelte';
import { formatCount, formatRetention } from '#lib/components/admin/format.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let currentUrl = $derived(page.shallow?.url ?? page.url);
let timeframe = $derived(
selectedTimeframe(data.timeframes, currentUrl.searchParams.get('period'))
);
let window = $derived(data.chart ? visitWindow(data.chart.period, timeframe) : null);
let granularity = $derived(
data.chart ? visitGranularity(timeframe, data.chart.period.retentionMonths) : 'day'
);
let validVisits = $derived(data.visitsByTimeframe[timeframe] ?? null);
function selectedTimeframe(
available: VisitTimeframe[],
requested: string | null
): VisitTimeframe {
return available.find((value) => value === requested) ?? '30d';
}
function timeframeLabel(value: VisitTimeframe): string {
if (value === '7d') return '7 days';
if (value === '30d') return '30 days';
if (value === '90d') return '90 days';
return `${data.retentionMonths} months`;
}
async function selectTimeframe(value: VisitTimeframe): Promise<void> {
if (value === timeframe) return;
const next = new URL(currentUrl.href);
if (value === '30d') next.searchParams.delete('period');
else next.searchParams.set('period', value);
await goto(next, { state: page.state, shallow: true });
}
</script>
<AdminPage siteTitle={data.title} title="Overview" updatedAt={data.asOf}>
{#snippet actions()}
{#if data.available && data.timeframes.length > 0}
<div class="overview-timeframes" role="group" aria-label="Timeframe">
{#each data.timeframes as option (option)}
<button
type="button"
class="ui-button"
aria-pressed={timeframe === option}
onclick={() => selectTimeframe(option)}>{timeframeLabel(option)}</button
>
{/each}
</div>
{/if}
{/snippet}
<ReportNotice collectionEnabled={data.collectionEnabled} available={data.available} />
<div class="admin-cards admin-cards-summary">
<div
class="admin-cards"
class:admin-cards-summary={data.collectionEnabled}
class:admin-cards-inventory={!data.collectionEnabled}
>
<StatCard label="Domains" value={data.domainCount} />
<StatCard label="Links" value={data.linkCount} />
<StatCard label="Valid visits" value={data.totals?.validVisits ?? null} />
<StatCard label="Retention" value={formatRetention(data.retentionMonths)} />
{#if data.collectionEnabled}
<StatCard label="Valid visits" value={validVisits} />
<StatCard label="Retention" value={formatRetention(data.retentionMonths)} />
{/if}
</div>
{#if data.chart && window}
<VisitsChart daily={data.chart.daily} {window} {granularity} />
{/if}
<section>
<h2>Domains</h2>
{#if data.domains.length === 0}<p>No domains configured.</p>
@@ -23,7 +82,9 @@
<table class="admin-table">
<thead
><tr
><th scope="col">Domain</th><th scope="col">Links</th><th scope="col">Visits</th></tr
><th scope="col">Domain</th><th scope="col">Links</th>{#if data.collectionEnabled}<th
scope="col">Visits</th
>{/if}</tr
></thead
>
<tbody>
@@ -31,7 +92,9 @@
<tr>
<th scope="row"><a class="admin-row-link" href={row.href}>{row.domain}</a></th>
<td>{formatCount(row.linkCount)}</td>
<td>{formatCount(row.counts?.validVisits ?? null)}</td>
{#if data.collectionEnabled}<td
>{formatCount(row.visitsByTimeframe[timeframe] ?? null)}</td
>{/if}
</tr>
{/each}
</tbody>
@@ -1,25 +1,37 @@
import { validateConfig } from '@goto/config';
import type { OverviewSummary } from '@goto/analytics';
import {
createVisitPeriod,
visitDays,
type ReportPeriod,
type ReportScope,
type VisitDetails
} from '@goto/analytics';
import { beforeEach, expect, test, vi } from 'vitest';
import { prepareSnapshot } from '#lib/server/snapshot.js';
import { readVisitSummary } from '#lib/server/visit-reports.js';
import { readOverviewDetails } from '#lib/server/visit-reports.js';
import { load } from './+page.server.js';
vi.mock('#lib/server/visit-reports.js', () => ({ readVisitSummary: vi.fn() }));
vi.mock('#lib/server/visit-reports.js', () => ({ readOverviewDetails: vi.fn() }));
beforeEach(() => vi.resetAllMocks());
const domain = 'go.example.test';
const emptyDomain = 'empty.example.test';
const newDomain = 'new.example.test';
function request(retention_months = 3) {
const prepared = validateConfig(
{
auth: { credentials: { username: 'test', password: 'overview-test-password' } },
analytics: { retention_months },
domains: {
'go.example.test': {
[domain]: {
links: {
'/docs': { url: 'https://example.test/docs' },
'/disabled': { url: 'https://example.test/disabled', enabled: false }
}
}
},
[emptyDomain]: { links: { '/': { url: 'https://example.test/' } } },
[newDomain]: { links: { '/new': { url: 'https://example.test/new' } } }
}
},
{ env: {} }
@@ -27,46 +39,140 @@ function request(retention_months = 3) {
return { locals: { snapshot: prepareSnapshot(prepared) } } as Parameters<typeof load>[0];
}
test('shows current configuration and the worker-prepared summary without re-aggregating', async () => {
const counts = { validVisits: 2, invalidVisits: 2 };
const summary: OverviewSummary = {
version: 1,
scope: { kind: 'overview' },
period: {
from: '2026-03-15T12:00:00.000Z',
asOf: '2026-06-15T12:00:00.000Z',
timezone: 'UTC',
retentionMonths: 3
},
totals: { validVisits: 7, invalidVisits: 2 },
base: { validVisits: 0, invalidVisits: 1 },
domains: [{ domain: 'go.example.test', totals: counts }]
function details(
scope: ReportScope,
period: ReportPeriod,
validVisits: (day: string) => number
): VisitDetails {
const daily = visitDays(period).map((day) => ({
...day,
validVisits: validVisits(day.start.slice(0, 10)),
invalidVisits: 1
}));
const totals = {
validVisits: daily.reduce((sum, day) => sum + day.validVisits, 0),
invalidVisits: daily.length
};
vi.mocked(readVisitSummary).mockResolvedValue(summary);
const data = await load(request());
if (!data) throw new Error('Missing page data');
expect(readVisitSummary).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
kind: 'overview'
return {
version: 1,
scope,
period,
daily,
browsers: [{ family: 'Other', ...totals }],
operatingSystems: [{ family: 'Other', ...totals }]
};
}
test('projects compact global window counts while inventory stays current and history stays retained', async () => {
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z'));
const overviewCounts: Record<string, number> = {
'2026-03-15': 13,
'2026-04-01': 11,
'2026-05-16': 7,
'2026-05-17': 5,
'2026-06-08': 3,
'2026-06-09': 2,
'2026-06-15': 1
};
const domainCounts: Record<string, number> = {
'2026-03-15': 4,
'2026-05-16': 3,
'2026-05-17': 2,
'2026-06-08': 1,
'2026-06-09': 1,
'2026-06-15': 1
};
const overview = details({ kind: 'overview' }, period, (day) => overviewCounts[day] ?? 0);
const current = details({ kind: 'domain', domain }, period, (day) => domainCounts[day] ?? 0);
const empty = details({ kind: 'domain', domain: emptyDomain }, period, () => 0);
vi.mocked(readOverviewDetails).mockResolvedValue({
overview,
domains: [
{ domain, details: current },
{ domain: emptyDomain, details: empty }
]
});
expect(data.domainCount).toBe(1);
expect(data.linkCount).toBe(2);
expect(data.totals).toEqual(summary.totals);
expect(data.asOf).toBe(Date.parse(summary.period.asOf));
expect(data.domains[0]).toMatchObject({
href: '/admin/domains/go.example.test',
counts
const data = await load(request());
expect(readOverviewDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), [
domain,
emptyDomain,
newDomain
]);
expect(data).toEqual({
collectionEnabled: true,
available: true,
retentionMonths: 3,
asOf: Date.parse(period.asOf),
domainCount: 3,
linkCount: 4,
timeframes: ['7d', '30d', 'all'],
visitsByTimeframe: { '7d': 3, '30d': 11, all: 42 },
chart: { daily: overview.daily, period },
domains: [
{
domain,
linkCount: 2,
visitsByTimeframe: { '7d': 2, '30d': 5, all: 12 },
href: '/admin/domains/go.example.test'
},
{
domain: emptyDomain,
linkCount: 1,
visitsByTimeframe: { '7d': 0, '30d': 0, all: 0 },
href: '/admin/domains/empty.example.test'
},
{
domain: newDomain,
linkCount: 1,
visitsByTimeframe: {},
href: '/admin/domains/new.example.test'
}
]
});
});
test('disabled collection does not read a report; missing preparation is not zero', async () => {
const disabled = await load(request(0));
if (!disabled) throw new Error('Missing disabled page');
expect(disabled.collectionEnabled).toBe(false);
expect(disabled.totals).toBeNull();
expect(readVisitSummary).not.toHaveBeenCalled();
vi.mocked(readVisitSummary).mockResolvedValue(null);
const unavailable = await load(request());
if (!unavailable) throw new Error('Missing unavailable page');
expect(unavailable.available).toBe(false);
expect(unavailable.domains[0]?.counts).toBeNull();
test.each([
{ months: 9 as const, retainedDays: 273 },
{ months: 12 as const, retainedDays: 365 }
])(
'offers four windows for $months months anchored to the latest included day',
async ({ months, retainedDays }) => {
const period = createVisitPeriod(months, Date.parse('2026-03-01T00:00:00.000Z'));
const overview = details({ kind: 'overview' }, period, () => 1);
vi.mocked(readOverviewDetails).mockResolvedValue({ overview, domains: [] });
const data = await load(request(months));
if (!data) throw new Error('Missing overview page');
expect(data.timeframes).toEqual(['7d', '30d', '90d', 'all']);
expect(data.visitsByTimeframe).toEqual({ '7d': 7, '30d': 30, '90d': 90, all: retainedDays });
expect(data.domains.every((row) => Object.keys(row.visitsByTimeframe).length === 0)).toBe(true);
}
);
test('missing preparation keeps inventory usable without substituting zero counts', async () => {
vi.mocked(readOverviewDetails).mockResolvedValue(null);
const data = await load(request());
if (!data) throw new Error('Missing unavailable page');
expect(data.available).toBe(false);
expect(data.collectionEnabled).toBe(true);
expect(data.retentionMonths).toBe(3);
expect(data.domainCount).toBe(3);
expect(data.linkCount).toBe(4);
expect(data.asOf).toBeNull();
expect(data.chart).toBeNull();
expect(data.visitsByTimeframe).toEqual({});
expect(data.domains.map((row) => row.visitsByTimeframe)).toEqual([{}, {}, {}]);
});
test('disabled collection reads no analytics and exposes only inventory with empty counts', async () => {
const data = await load(request(0));
if (!data) throw new Error('Missing disabled page');
expect(data.collectionEnabled).toBe(false);
expect(data.available).toBe(false);
expect(data.domainCount).toBe(3);
expect(data.linkCount).toBe(4);
expect(data.timeframes).toEqual([]);
expect(data.visitsByTimeframe).toEqual({});
expect(data.chart).toBeNull();
expect(data.domains.map((row) => row.visitsByTimeframe)).toEqual([{}, {}, {}]);
expect(readOverviewDetails).not.toHaveBeenCalled();
});
+15 -13
View File
@@ -3,24 +3,26 @@ FROM node:26.5.0-alpine AS base
WORKDIR /m
ENV CI=true \
HOME=/home/app \
PNPM_HOME=/home/app/.pnpm \
XDG_DATA_HOME=/home/app/.local/share \
XDG_CACHE_HOME=/home/app/.cache \
XDG_CONFIG_HOME=/home/app/.config
HOME=/root
RUN npm install -g pnpm@11.9.0 \
&& mkdir -p /home/app/store \
&& printf 'store-dir=/home/app/store\n' > /home/app/.npmrc
&& mkdir -p /pnpm/store \
&& printf 'store-dir=/pnpm/store\n' > /root/.npmrc
FROM base AS dev
ARG LOCAL_UID=1000
ARG LOCAL_GID=1000
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile
RUN mkdir -p /m/apps/worker/config /m/apps/worker/data /m/packages/config/dist \
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile
ENV HOME=/home/app \
PNPM_HOME=/home/app/.pnpm \
XDG_DATA_HOME=/home/app/.local/share \
XDG_CACHE_HOME=/home/app/.cache \
XDG_CONFIG_HOME=/home/app/.config
RUN mkdir -p /home/app/store /m/apps/worker/config /m/apps/worker/data /m/packages/config/dist \
&& printf 'store-dir=/home/app/store\n' > /home/app/.npmrc \
&& chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/apps/worker/data /m/packages/config/dist
USER ${LOCAL_UID}:${LOCAL_GID}
WORKDIR /m/apps/worker
@@ -29,13 +31,13 @@ CMD ["pnpm", "run", "dev"]
FROM base AS builder
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile
RUN pnpm --filter "@goto/worker..." build
FROM builder AS pruner
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store \
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm --config.hoist-workspace-packages=false --filter @goto/worker --prod deploy --legacy /pruned
FROM node:26.5.0-alpine AS prod
+5
View File
@@ -8,6 +8,11 @@
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./daily": {
"development": "./src/daily.ts",
"types": "./dist/daily.d.ts",
"import": "./dist/daily.js"
}
},
"files": [
+245
View File
@@ -0,0 +1,245 @@
import { expect, test } from 'vitest';
import {
availableVisitTimeframes,
groupVisitDays,
sumValidVisits,
visitGranularity,
visitWindow
} from './daily.js';
import { visitDays } from './period.js';
import type { ReportPeriod, VisitDetails } from './reports.js';
function dailyReport(
from: string,
asOf: string,
counts: Record<string, [number, number]> = {}
): { period: ReportPeriod; daily: VisitDetails['daily'] } {
const period: ReportPeriod = { from, asOf, timezone: 'UTC', retentionMonths: 3 };
const daily = visitDays(period).map((day) => {
const [validVisits, invalidVisits] = counts[day.start.slice(0, 10)] ?? [0, 0];
return { ...day, validVisits, invalidVisits };
});
return { period, daily };
}
test('timeframe choices and automatic granularity follow retention', () => {
expect(availableVisitTimeframes(3)).toEqual(['7d', '30d', 'all']);
expect(availableVisitTimeframes(9)).toEqual(['7d', '30d', '90d', 'all']);
expect(availableVisitTimeframes(12)).toEqual(['7d', '30d', '90d', 'all']);
expect(visitGranularity('7d', 3)).toBe('day');
expect(visitGranularity('30d', 9)).toBe('day');
expect(visitGranularity('90d', 12)).toBe('week');
expect(visitGranularity('all', 3)).toBe('week');
expect(visitGranularity('all', 9)).toBe('month');
expect(visitGranularity('all', 12)).toBe('month');
});
test('day presets include the latest UTC calendar day rather than rolling hours', () => {
const period: ReportPeriod = {
from: '2023-12-01T12:00:00.000Z',
asOf: '2024-03-01T12:00:00.000Z',
timezone: 'UTC',
retentionMonths: 3
};
expect(visitWindow(period, '7d')).toEqual({
from: '2024-02-24T00:00:00.000Z',
asOf: period.asOf
});
expect(visitWindow({ ...period, asOf: '2024-03-01T00:00:00.000Z' }, '7d')).toEqual({
from: '2024-02-23T00:00:00.000Z',
asOf: '2024-03-01T00:00:00.000Z'
});
expect(visitWindow(period, '30d')).toEqual({
from: '2024-02-01T00:00:00.000Z',
asOf: period.asOf
});
expect(visitWindow(period, '90d')).toEqual({
from: '2023-12-03T00:00:00.000Z',
asOf: period.asOf
});
});
test('full windows preserve partial retention boundaries and presets cannot precede them', () => {
const { period, daily } = dailyReport('2024-02-28T12:00:00.000Z', '2024-03-01T12:00:00.000Z', {
'2024-02-28': [2, 1],
'2024-02-29': [3, 0],
'2024-03-01': [4, 1]
});
const original = { ...period };
const window = visitWindow(period, 'all');
expect(window).toEqual({ from: period.from, asOf: period.asOf });
expect(visitWindow(period, '7d')).toEqual(window);
expect(sumValidVisits(daily, window)).toBe(9);
expect(groupVisitDays(daily, window, 'day')).toEqual(daily);
expect(period).toEqual(original);
});
test('selected days are filtered before grouping and share the headline total without mutation', () => {
const { period, daily } = dailyReport('2024-02-20T12:00:00.000Z', '2024-03-05T00:00:00.000Z', {
'2024-02-26': [100, 100],
'2024-02-27': [2, 50],
'2024-02-29': [3, 1],
'2024-03-04': [4, 2]
});
const original = structuredClone(daily);
const window = visitWindow(period, '7d');
const weeks = groupVisitDays(daily, window, 'week');
expect(window.from).toBe('2024-02-27T00:00:00.000Z');
expect(weeks).toEqual([
{
start: '2024-02-26T00:00:00.000Z',
end: '2024-03-04T00:00:00.000Z',
partial: true,
validVisits: 5,
invalidVisits: 51
},
{
start: '2024-03-04T00:00:00.000Z',
end: '2024-03-11T00:00:00.000Z',
partial: true,
validVisits: 4,
invalidVisits: 2
}
]);
expect(sumValidVisits(daily, window)).toBe(9);
expect(weeks.reduce((total, week) => total + week.validVisits, 0)).toBe(9);
expect(daily).toEqual(original);
});
test('days touching but outside the view are excluded from groups and totals', () => {
const { daily } = dailyReport('2024-02-27T00:00:00.000Z', '2024-03-02T00:00:00.000Z', {
'2024-02-27': [100, 100],
'2024-02-28': [2, 1],
'2024-02-29': [3, 2],
'2024-03-01': [100, 100]
});
const window = { from: '2024-02-28T00:00:00.000Z', asOf: '2024-03-01T00:00:00.000Z' };
expect(sumValidVisits(daily, window)).toBe(5);
expect(groupVisitDays(daily, window, 'day')).toEqual(daily.slice(1, 3));
});
test('day grouping preserves UTC leap days, zero counts and partial window edges', () => {
const { period, daily } = dailyReport('2024-02-28T12:00:00.000Z', '2024-03-01T12:00:00.000Z', {
'2024-02-28': [2, 1],
'2024-03-01': [4, 3]
});
expect(groupVisitDays(daily, period, 'day')).toEqual([
{
start: '2024-02-28T00:00:00.000Z',
end: '2024-02-29T00:00:00.000Z',
partial: true,
validVisits: 2,
invalidVisits: 1
},
{
start: '2024-02-29T00:00:00.000Z',
end: '2024-03-01T00:00:00.000Z',
partial: false,
validVisits: 0,
invalidVisits: 0
},
{
start: '2024-03-01T00:00:00.000Z',
end: '2024-03-02T00:00:00.000Z',
partial: true,
validVisits: 4,
invalidVisits: 3
}
]);
});
test('weeks start on UTC Monday across years and sum both outcomes without changing input', () => {
const { period, daily } = dailyReport('2023-12-31T12:00:00.000Z', '2024-01-09T12:00:00.000Z', {
'2023-12-31': [2, 1],
'2024-01-08': [4, 3],
'2024-01-09': [1, 2]
});
const original = structuredClone(daily);
expect(groupVisitDays(daily, period, 'week')).toEqual([
{
start: '2023-12-25T00:00:00.000Z',
end: '2024-01-01T00:00:00.000Z',
partial: true,
validVisits: 2,
invalidVisits: 1
},
{
start: '2024-01-01T00:00:00.000Z',
end: '2024-01-08T00:00:00.000Z',
partial: false,
validVisits: 0,
invalidVisits: 0
},
{
start: '2024-01-08T00:00:00.000Z',
end: '2024-01-15T00:00:00.000Z',
partial: true,
validVisits: 5,
invalidVisits: 5
}
]);
expect(daily).toEqual(original);
});
test('calendar months preserve empty months and include leap-day outcomes in February', () => {
const { period, daily } = dailyReport('2023-12-31T12:00:00.000Z', '2024-03-01T12:00:00.000Z', {
'2023-12-31': [2, 1],
'2024-02-28': [3, 2],
'2024-02-29': [4, 1],
'2024-03-01': [1, 5]
});
expect(groupVisitDays(daily, period, 'month')).toEqual([
{
start: '2023-12-01T00:00:00.000Z',
end: '2024-01-01T00:00:00.000Z',
partial: true,
validVisits: 2,
invalidVisits: 1
},
{
start: '2024-01-01T00:00:00.000Z',
end: '2024-02-01T00:00:00.000Z',
partial: false,
validVisits: 0,
invalidVisits: 0
},
{
start: '2024-02-01T00:00:00.000Z',
end: '2024-03-01T00:00:00.000Z',
partial: false,
validVisits: 7,
invalidVisits: 3
},
{
start: '2024-03-01T00:00:00.000Z',
end: '2024-04-01T00:00:00.000Z',
partial: true,
validVisits: 1,
invalidVisits: 5
}
]);
});
test('exact calendar boundaries are complete and do not add a following bucket', () => {
const { period, daily } = dailyReport('2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z');
expect(groupVisitDays(daily, period, 'month')).toEqual([
{
start: '2024-02-01T00:00:00.000Z',
end: '2024-03-01T00:00:00.000Z',
partial: false,
validVisits: 0,
invalidVisits: 0
}
]);
expect(sumValidVisits(daily, period)).toBe(0);
expect(sumValidVisits([], period)).toBe(0);
expect(groupVisitDays([], period, 'month')).toEqual([]);
});
+99
View File
@@ -0,0 +1,99 @@
import type { ReportPeriod, VisitDetails } from './reports.js';
export type VisitTimeframe = '7d' | '30d' | '90d' | 'all';
export type VisitWindow = { from: string; asOf: string };
export type VisitGranularity = 'day' | 'week' | 'month';
const daysByTimeframe = { '7d': 7, '30d': 30, '90d': 90 };
export function availableVisitTimeframes(months: 3 | 9 | 12): VisitTimeframe[] {
return months === 3 ? ['7d', '30d', 'all'] : ['7d', '30d', '90d', 'all'];
}
export function visitWindow(period: ReportPeriod, timeframe: VisitTimeframe): VisitWindow {
if (timeframe === 'all') {
return { from: period.from, asOf: period.asOf };
}
const days = daysByTimeframe[timeframe];
const start = new Date(Date.parse(period.asOf) - 1);
start.setUTCHours(0, 0, 0, 0);
start.setUTCDate(start.getUTCDate() - (days - 1));
return {
from: new Date(Math.max(start.getTime(), Date.parse(period.from))).toISOString(),
asOf: period.asOf
};
}
export function sumValidVisits(daily: VisitDetails['daily'], window: VisitWindow): number {
const from = Date.parse(window.from);
const asOf = Date.parse(window.asOf);
let total = 0;
for (const day of daily) {
if (intersectsWindow(day, from, asOf)) {
total += day.validVisits;
}
}
return total;
}
export function visitGranularity(timeframe: VisitTimeframe, months: 3 | 9 | 12): VisitGranularity {
if (timeframe === '7d' || timeframe === '30d') return 'day';
if (timeframe === '90d' || months === 3) return 'week';
return 'month';
}
export function groupVisitDays(
daily: VisitDetails['daily'],
window: VisitWindow,
granularity: VisitGranularity
): VisitDetails['daily'] {
const groups: VisitDetails['daily'] = [];
const from = Date.parse(window.from);
const asOf = Date.parse(window.asOf);
for (const day of daily) {
if (!intersectsWindow(day, from, asOf)) continue;
const start = groupStart(day.start, granularity);
const startInstant = start.toISOString();
const previous = groups.at(-1);
if (previous?.start === startInstant) {
previous.validVisits += day.validVisits;
previous.invalidVisits += day.invalidVisits;
continue;
}
const end = new Date(start);
if (granularity === 'month') {
end.setUTCMonth(end.getUTCMonth() + 1);
} else {
end.setUTCDate(end.getUTCDate() + (granularity === 'week' ? 7 : 1));
}
groups.push({
start: startInstant,
end: end.toISOString(),
partial: start.getTime() < from || end.getTime() > asOf,
validVisits: day.validVisits,
invalidVisits: day.invalidVisits
});
}
return groups;
}
function intersectsWindow(day: VisitDetails['daily'][number], from: number, asOf: number): boolean {
return Date.parse(day.start) < asOf && Date.parse(day.end) > from;
}
function groupStart(instant: string, granularity: VisitGranularity): Date {
const start = new Date(instant);
start.setUTCHours(0, 0, 0, 0);
if (granularity === 'week') {
const daysSinceMonday = (start.getUTCDay() + 6) % 7;
start.setUTCDate(start.getUTCDate() - daysSinceMonday);
} else if (granularity === 'month') {
start.setUTCDate(1);
}
return start;
}
+20
View File
@@ -30,6 +30,26 @@ test('readers decode their self-contained document independently', () => {
expect(parseVisitDetails(JSON.stringify(details), scope)).toEqual(details);
});
test('details reader can require the same reporting policy and window', () => {
expect(parseVisitDetails(JSON.stringify(details), scope, period)).toEqual(details);
const nextPeriod = createVisitPeriod(3, Date.UTC(2026, 3, 2, 12));
const nextDetails = {
...details,
period: nextPeriod,
daily: visitDays(nextPeriod).map((day) => ({ ...day, ...zeroVisitTotals() })),
browsers: [],
operatingSystems: []
};
expect(parseVisitDetails(JSON.stringify(nextDetails), scope)).toEqual(nextDetails);
expect(() => parseVisitDetails(JSON.stringify(nextDetails), scope, period)).toThrow(
'Invalid analytics report'
);
const otherPolicy = createVisitPeriod(9, Date.parse(period.asOf));
expect(() => parseVisitDetails(JSON.stringify(details), scope, otherPolicy)).toThrow(
'Invalid analytics report'
);
});
test('summary reader rejects unsupported versions, unsafe counts and wrong identities safely', () => {
for (const invalid of [
{ ...summary, version: 2 },
+8 -3
View File
@@ -1,5 +1,5 @@
import type { ReportScope, VisitDetails, VisitSummary } from './reports.js';
import { checkDetails, checkSummary, invalidReport, sameScope } from './validation.js';
import type { ReportPeriod, ReportScope, VisitDetails, VisitSummary } from './reports.js';
import { checkDetails, checkSummary, invalidReport, samePeriod, sameScope } from './validation.js';
export function parseVisitSummary(json: string, expectedScope: ReportScope): VisitSummary {
const document = parseDocument(json);
@@ -8,10 +8,15 @@ export function parseVisitSummary(json: string, expectedScope: ReportScope): Vis
return summary;
}
export function parseVisitDetails(json: string, expectedScope: ReportScope): VisitDetails {
export function parseVisitDetails(
json: string,
expectedScope: ReportScope,
expectedPeriod?: ReportPeriod
): VisitDetails {
const document = parseDocument(json);
const details = checkDetails(document);
if (!sameScope(details.scope, expectedScope)) throw invalidReport();
if (expectedPeriod && !samePeriod(details.period, expectedPeriod)) throw invalidReport();
return details;
}
+2
View File
@@ -2,9 +2,11 @@ export { aggregateVisitReports } from './queries.js';
export {
clearVisitReports,
publishVisitReports,
readOverviewVisitDetails,
readVisitSummary,
readVisitDetails
} from './storage.js';
export type { OverviewVisitDetails } from './storage.js';
export type {
AggregatedVisitReports,
DailyVisitCounts,
+88 -2
View File
@@ -16,6 +16,7 @@ import {
clearVisitReports,
publishVisitReports,
readVisitDetails,
readOverviewVisitDetails,
readVisitSummary
} from './storage.js';
@@ -38,9 +39,10 @@ afterEach(async () => {
function preparedReport(
scope: ReportScope,
cutoff = asOf,
totals: VisitTotals = zeroVisitTotals()
totals: VisitTotals = zeroVisitTotals(),
retentionMonths: 3 | 9 | 12 = 3
): PreparedVisitReport {
const period = createVisitPeriod(3, cutoff);
const period = createVisitPeriod(retentionMonths, cutoff);
let summary: VisitSummary;
if (scope.kind === 'overview') {
summary = { version: 1, scope, period, totals, base: zeroVisitTotals(), domains: [] };
@@ -91,6 +93,90 @@ test('reads summary and details for each explicit SQL scope including root', asy
]);
});
test('overview details use one SQL snapshot with only current domain details and no summaries', async () => {
const overview = preparedReport(overviewScope, asOf, { validVisits: 8, invalidVisits: 2 });
const current = preparedReport(domainScope, asOf, { validVisits: 3, invalidVisits: 1 });
const historical = preparedReport({ kind: 'domain', domain: 'removed.example.test' });
await publishVisitReports(
db,
encodeVisitReports([overview, current, historical, preparedReport(rootScope)])
);
await db.updateTable('analytics_reports').set({ summary_json: '{}' }).execute();
await db
.updateTable('analytics_reports')
.set({ details_json: '{}' })
.where(({ or, eb }) =>
or([eb('scope_kind', '=', 'link'), eb('domain', '=', 'removed.example.test')])
)
.execute();
const statements = vi.spyOn(DatabaseSync.prototype, 'prepare');
try {
expect(await readOverviewVisitDetails(db, [domain, 'new.example.test'])).toEqual({
overview: overview.details,
domains: [{ domain, details: current.details }]
});
expect(statements).toHaveBeenCalledTimes(1);
const query = statements.mock.calls[0]?.[0];
expect(query).toMatch(
/^select\s+"scope_kind",\s*"domain",\s*"details_json"\s+from\s+"analytics_reports"/i
);
expect(query).not.toMatch(/summary_json|\bvisits\b|\bjoin\b/i);
} finally {
statements.mockRestore();
}
});
test('overview details preserve missing reports rather than inventing zero domains', async () => {
expect(await readOverviewVisitDetails(db, [domain])).toBeNull();
const current = preparedReport(domainScope);
await publishVisitReports(db, encodeVisitReports([current]));
expect(await readOverviewVisitDetails(db, [domain])).toBeNull();
const overview = preparedReport(overviewScope);
await publishVisitReports(db, encodeVisitReports([overview, current]));
expect(await readOverviewVisitDetails(db, [])).toEqual({
overview: overview.details,
domains: []
});
expect(await readOverviewVisitDetails(db, ['new.example.test'])).toEqual({
overview: overview.details,
domains: []
});
});
test('overview details reject malformed or mismatched domain scope, policy and window', async () => {
const overview = preparedReport(overviewScope);
const current = preparedReport(domainScope);
await publishVisitReports(db, encodeVisitReports([overview, current]));
for (const details of [
{},
{ ...current.details, version: 2 },
overview.details,
{ ...current.details, scope: { kind: 'domain', domain: 'wrong.example.test' } },
preparedReport(domainScope, asOf + 86_400_000).details,
preparedReport(domainScope, asOf, zeroVisitTotals(), 9).details
]) {
await db
.updateTable('analytics_reports')
.set({ details_json: JSON.stringify(details) })
.where('scope_kind', '=', 'domain')
.execute();
await expect(readOverviewVisitDetails(db, [domain])).rejects.toThrow(
/^Invalid analytics report$/
);
}
});
test('overview details reject an invalid overview independently of summary JSON', async () => {
await publishVisitReports(db, encodeVisitReports([preparedReport(overviewScope)]));
for (const details of [{}, preparedReport(domainScope).details]) {
await db
.updateTable('analytics_reports')
.set({ details_json: JSON.stringify(details) })
.execute();
await expect(readOverviewVisitDetails(db, [])).rejects.toThrow(/^Invalid analytics report$/);
}
});
test('publication replaces the complete batch, including when the replacement is empty', async () => {
await publishVisitReports(
db,
+40
View File
@@ -36,6 +36,46 @@ export async function publishVisitReports(
});
}
export interface OverviewVisitDetails {
overview: VisitDetails;
domains: Array<{ domain: string; details: VisitDetails }>;
}
export async function readOverviewVisitDetails(
db: Kysely<Database>,
currentDomains: string[]
): Promise<OverviewVisitDetails | null> {
const rows = await db
.selectFrom('analytics_reports')
.select(['scope_kind', 'domain', 'details_json'])
.where(({ and, or, eb }) => {
const overview = eb('scope_kind', '=', 'overview');
if (currentDomains.length === 0) return overview;
return or([
overview,
and([eb('scope_kind', '=', 'domain'), eb('domain', 'in', currentDomains)])
]);
})
.execute();
const overviewRow = rows.find((row) => row.scope_kind === 'overview');
if (!overviewRow) return null;
const overview = parseVisitDetails(overviewRow.details_json, { kind: 'overview' });
const domains: OverviewVisitDetails['domains'] = [];
for (const row of rows) {
if (row.scope_kind === 'overview') continue;
if (row.scope_kind !== 'domain' || row.domain === null) {
throw new Error('Invalid analytics report');
}
const details = parseVisitDetails(
row.details_json,
{ kind: 'domain', domain: row.domain },
overview.period
);
domains.push({ domain: row.domain, details });
}
return { overview, domains };
}
export async function readVisitSummary(
db: Kysely<Database>,
scope: ReportScope
+19
View File
@@ -39,6 +39,9 @@ catalogs:
better-auth:
specifier: 1.7.14
version: 1.7.14
chart.js:
specifier: 4.6.1
version: 4.6.1
esbuild:
specifier: 0.28.2
version: 0.28.2
@@ -178,6 +181,9 @@ importers:
better-auth:
specifier: 'catalog:'
version: 1.7.14(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vitest@4.1.7)
chart.js:
specifier: 'catalog:'
version: 4.6.1
piscina:
specifier: 'catalog:'
version: 5.3.2
@@ -730,6 +736,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@manypkg/find-root@3.1.0':
resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==}
engines: {node: '>=20.0.0'}
@@ -1259,6 +1268,10 @@ packages:
resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
engines: {node: '>=18'}
chart.js@4.6.1:
resolution: {integrity: sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g==}
engines: {pnpm: '>=8'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -2428,6 +2441,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@kurkle/color@0.3.4': {}
'@manypkg/find-root@3.1.0':
dependencies:
'@manypkg/tools': 2.1.2
@@ -2866,6 +2881,10 @@ snapshots:
chai@6.3.0: {}
chart.js@4.6.1:
dependencies:
'@kurkle/color': 0.3.4
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
+1
View File
@@ -14,6 +14,7 @@ catalog:
'@types/node': 24.12.4
'@vitest/coverage-v8': 4.1.7
better-auth: 1.7.14
chart.js: 4.6.1
esbuild: 0.28.2
eslint: 10.4.1
eslint-config-prettier: 10.1.8