feat(admin): add timeframe-aware overview visits chart #16
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+19
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user