feat(admin): add domain visit charts #19

Merged
eleith merged 2 commits from feat/domain-visits-chart into main 2026-11-11 01:34:53 +00:00
26 changed files with 1264 additions and 349 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@goto/web': minor
'@goto/worker': minor
'@goto/cli': minor
---
Add the visits chart and retention-aware timeframes to domain dashboards. Reuse the overview chart, timeframe control and window-count helpers so domain traffic cards, base URL and current-link counts follow one selected window. Preserve the selected timeframe between overview and domain pages, keep link series out of browser data, and show inventory only when analytics is disabled. Use contextual page headers, compact mobile timeframe dropdowns and responsive inventory rows that fit without horizontal scrolling. Each inventory row is a single link to its statistics page, with plain-text details. Individual-link analytics remain full-retention summaries.
+102 -24
View File
@@ -46,8 +46,9 @@ test('shared credentials protect overview, domain and link pages and sign-out re
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 domainRow = page.getByRole('listitem').filter({ has: domainLink });
const domainCounts = domainRow.locator('.admin-inventory-number');
await expect(domainCounts).toHaveText(['6', '1']);
const selectionRequests: string[] = [];
const recordSelectionRequest = (request: Request) => {
const type = request.resourceType();
@@ -63,7 +64,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
'true'
);
await expect(visitsValue).toHaveText('1');
await expect(domainRow.getByRole('cell')).toHaveText(['6', '0']);
await expect(domainCounts).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(
@@ -71,7 +72,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
'true'
);
await expect(visitsValue).toHaveText('3');
await expect(domainRow.getByRole('cell')).toHaveText(['6', '2']);
await expect(domainCounts).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(
@@ -79,7 +80,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
'true'
);
await expect(visitsValue).toHaveText('1');
await expect(domainRow.getByRole('cell')).toHaveText(['6', '0']);
await expect(domainCounts).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(
@@ -87,7 +88,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
'true'
);
await expect(visitsValue).toHaveText('3');
await expect(domainRow.getByRole('cell')).toHaveText(['6', '2']);
await expect(domainCounts).toHaveText(['6', '2']);
await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 3\./);
page.off('request', recordSelectionRequest);
expect(selectionRequests).toEqual([]);
@@ -100,8 +101,21 @@ test('shared credentials protect overview, domain and link pages and sign-out re
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(page.getByRole('heading', { name: 'Domain', exact: true })).toBeVisible();
const context = page.locator('.admin-context');
await expect(context).toHaveText('go.example.org');
await expect(page).toHaveTitle('Domain — go.example.org — Goto');
const overviewLink = page
.getByRole('navigation', { name: 'Administration', exact: true })
.getByRole('link');
await expect(overviewLink).toHaveAttribute('href', '/admin?period=all');
await expect(page.getByRole('link', { name: 'Overview', exact: true })).toHaveCount(0);
await expect(trend).toHaveAttribute('aria-busy', 'false');
await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 2\./);
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(visitsValue).toHaveText('2');
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([
'Links',
@@ -112,19 +126,55 @@ test('shared credentials protect overview, domain and link pages and sign-out re
await expect(
page.getByRole('region', { name: 'Invalid clicks', exact: true }).locator('.admin-stat-value')
).toHaveText('1');
const linksTable = page.getByRole('region', { name: 'Links on go.example.org', exact: true });
const baseTable = page.getByRole('region', { name: 'Base URL on go.example.org', exact: true });
const linksInventory = page.getByRole('region', { name: 'Links on go.example.org', exact: true });
const baseInventory = page.getByRole('region', {
name: 'Base URL on go.example.org',
exact: true
});
const columns = ['Path', 'Destination', 'State', 'Visits'];
await expect(linksTable.getByRole('columnheader')).toHaveText(columns);
const historicalInvalid = linksTable
.getByRole('row')
await expect(linksInventory.locator('.admin-inventory-heading > span')).toHaveText(columns);
const retainedLink = linksInventory
.getByRole('listitem')
.filter({ has: page.getByRole('link', { name: '/a%2Fb', exact: true }) });
await expect(retainedLink.getByRole('link')).toHaveCount(1);
const retainedVisits = retainedLink.locator('.admin-inventory-number');
await expect(retainedVisits).toHaveText('1');
await page.setViewportSize({ width: 390, height: 844 });
const mobileTimeframe = page.getByRole('button', { name: /^Timeframe: / });
await expect(mobileTimeframe).toContainText('3 months');
await mobileTimeframe.click();
await page
.getByRole('listbox', { name: 'Timeframe', exact: true })
.getByRole('option', { name: '30 days', exact: true })
.click();
await expect(visitsValue).toHaveText('1');
await expect(retainedVisits).toHaveText('0');
await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 1\./);
expect(
await linksInventory.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true);
await page.setViewportSize({ width: 1280, height: 720 });
await timeframe.getByRole('button', { name: '3 months', exact: true }).click();
await expect(visitsValue).toHaveText('2');
await expect(retainedVisits).toHaveText('1');
await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 2\./);
const historicalInvalid = linksInventory
.getByRole('listitem')
.filter({ has: page.getByRole('link', { name: '/a/b', exact: true }) });
await expect(historicalInvalid.getByRole('cell').last()).toHaveText('0');
await expect(baseTable.getByRole('columnheader')).toHaveText(columns);
await expect(baseTable.getByRole('cell')).toHaveText(['404 Not found', 'Fallback', '0']);
await expect(historicalInvalid.locator('.admin-inventory-number')).toHaveText('0');
await expect(baseInventory.locator('.admin-inventory-heading > span')).toHaveText(columns);
await expect(
linksTable.getByRole('link', { name: 'https://example.org/first-domain', exact: true })
baseInventory.locator(
'.admin-inventory-secondary, .admin-inventory-state, .admin-inventory-number'
)
).toHaveText(['404 Not found', 'Fallback', '0']);
await expect(
linksInventory.getByText('https://example.org/first-domain', { exact: true })
).toBeVisible();
await expect(
linksInventory.getByRole('link', { name: 'https://example.org/first-domain', exact: true })
).toHaveCount(0);
await expect(baseInventory.getByRole('link')).toHaveCount(1);
const domainBreadcrumb = page
.getByRole('navigation', { name: 'Breadcrumb', exact: true })
@@ -132,7 +182,10 @@ test('shared credentials protect overview, domain and link pages and sign-out re
const docsPath = '/admin/domains/go.example.org/links/docs';
await page.getByRole('link', { name: '/docs', exact: true }).click();
await expect(page).toHaveURL(`${appServer.origin}${docsPath}`);
await expect(page.getByRole('heading', { name: '/docs', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible();
await expect(context).toHaveText('go.example.org /docs');
await expect(page).toHaveTitle('Link — go.example.org/docs — Goto');
await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(0);
await expect(visitsValue).toHaveText('1');
await expect(
page.getByRole('region', { name: 'Links on go.example.org', exact: true })
@@ -140,22 +193,29 @@ test('shared credentials protect overview, domain and link pages and sign-out re
await domainBreadcrumb.click();
await page.getByRole('link', { name: '/a%2Fb', exact: true }).click();
await expect(page.getByRole('heading', { name: '/a%2Fb', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible();
await expect(context).toHaveText('go.example.org /a%2Fb');
await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links/a%252Fb`);
await expect(visitsValue).toHaveText('1');
await domainBreadcrumb.click();
await page.getByRole('link', { name: '/a/b', exact: true }).click();
await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links/a/b`);
await expect(page.getByRole('heading', { name: '/a/b', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible();
await expect(context).toHaveText('go.example.org /a/b');
await expect(visitsValue).toHaveText('0');
await domainBreadcrumb.click();
await baseTable.getByRole('link', { name: '/', exact: true }).click();
await baseInventory.getByRole('link', { name: '/', exact: true }).click();
await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links`);
await expect(page.getByRole('heading', { name: '/', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible();
await expect(context).toHaveText('go.example.org /');
await expect(visitsValue).toHaveText('0');
await domainBreadcrumb.click();
await expect(visitsValue).toHaveText('2');
await expect(timeframe.getByRole('button', { name: '30 days', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(visitsValue).toHaveText('1');
const html = await page.content();
await expect(html).not.toContain('e2e-only-password');
@@ -184,8 +244,26 @@ test.describe('analytics disabled', () => {
0
);
await expect(
page.getByRole('region', { name: 'Domain inventory', exact: true }).getByRole('columnheader')
page
.getByRole('region', { name: 'Domain inventory', exact: true })
.locator('.admin-inventory-heading > span')
).toHaveText(['Domain', 'Links']);
await expect(page.getByRole('status')).toHaveText('Analytics disabled.');
await page.getByRole('link', { name: 'go.example.org', exact: true }).click();
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText(['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: 'Links on go.example.org', exact: true })
.locator('.admin-inventory-heading > span')
).toHaveText(['Path', 'Destination', 'State']);
await expect(
page
.getByRole('region', { name: 'Base URL on go.example.org', exact: true })
.locator('.admin-inventory-heading > span')
).toHaveText(['Path', 'Destination', 'State']);
});
});
+1
View File
@@ -28,6 +28,7 @@
"@goto/config": "workspace:*",
"@goto/db": "workspace:*",
"better-auth": "catalog:",
"bits-ui": "catalog:",
"chart.js": "catalog:",
"piscina": "catalog:"
},
@@ -4,7 +4,7 @@
import { authClient } from '#lib/auth-client.js';
import ThemeToggle from '../ThemeToggle.svelte';
let { title }: { title: string } = $props();
let { title, overviewHref }: { title: string; overviewHref: string } = $props();
let currentPath = $derived(page.url.pathname + page.url.search);
let pending = $state(false);
@@ -49,7 +49,8 @@
<header class="admin-nav">
<div class="nav-container">
<nav aria-label="Administration">
<a href="/admin" aria-current={page.url.pathname === '/admin' ? 'page' : undefined}>{title}</a
<a href={overviewHref} aria-current={page.url.pathname === '/admin' ? 'page' : undefined}
>{title}</a
>
</nav>
<div class="nav-actions">
@@ -1,40 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import AdminNav from './AdminNav.svelte';
import { formatUpdated } from './format.js';
import { formatUpdated, formatUpdatedCompact } from './format.js';
import '#lib/styles/admin.css';
let {
siteTitle,
title,
documentTitle,
context,
overviewHref = '/admin',
updatedAt = null,
backHref = '',
backLabel = 'Overview',
actions,
children
}: {
siteTitle: string;
title: string;
documentTitle?: string;
context?: Snippet;
overviewHref?: string;
updatedAt?: number | null;
backHref?: string;
backLabel?: string;
actions?: Snippet;
children: Snippet;
} = $props();
let updatedText = $derived(formatUpdated(updatedAt));
let compactUpdatedText = $derived(formatUpdatedCompact(updatedAt));
</script>
<svelte:head><title>{title} — {siteTitle}</title></svelte:head>
<AdminNav title={siteTitle} />
<svelte:head><title>{documentTitle ?? title} — {siteTitle}</title></svelte:head>
<AdminNav title={siteTitle} {overviewHref} />
<main class="admin-page">
<header class="admin-heading">
{#if backHref}<nav aria-label="Breadcrumb"><a href={backHref}>{backLabel}</a></nav>{/if}
<div class="admin-title-row">
<div class="admin-title">
<h1>{title}</h1>
{#if context}<div class="admin-context">{@render context()}</div>{/if}
{#if updatedAt !== null}
<time class="admin-updated" datetime={new Date(updatedAt).toISOString()}
>{formatUpdated(updatedAt)}</time
<time
class="admin-updated"
datetime={new Date(updatedAt).toISOString()}
title={updatedText}
>
<span class="admin-updated-full">{updatedText}</span>
<span class="admin-updated-compact">{compactUpdatedText}</span>
</time>
{/if}
</div>
{@render actions?.()}
@@ -0,0 +1,53 @@
<script lang="ts">
import type { VisitCountsByTimeframe, VisitTimeframe } from '@goto/analytics/daily';
import { formatCount } from './format.js';
let {
label,
links,
timeframe,
collectionEnabled
}: {
label: string;
links: Array<{
path: string;
href: string;
url: string | null;
state: string;
countsByTimeframe: VisitCountsByTimeframe;
}>;
timeframe: VisitTimeframe;
collectionEnabled: boolean;
} = $props();
</script>
<div
class="admin-inventory admin-link-inventory"
class:with-visits={collectionEnabled}
role="region"
aria-label={label}
>
<div class="admin-inventory-heading" aria-hidden="true">
<span>Path</span><span>Destination</span><span>State</span>
{#if collectionEnabled}<span>Visits</span>{/if}
</div>
<ul class="admin-inventory-list">
{#each links as link (link.path)}
<li>
<a class="admin-inventory-row admin-inventory-link" href={link.href} aria-label={link.path}>
<span class="admin-inventory-primary"><code>{link.path}</code></span>
<span class="admin-inventory-secondary">{link.url ?? '404 Not found'}</span>
<span class="admin-inventory-state">{link.state}</span>
{#if collectionEnabled}
<span class="admin-inventory-count">
<span class="admin-inventory-label">Visits</span>
<span class="admin-inventory-number"
>{formatCount(link.countsByTimeframe[timeframe]?.validVisits ?? null)}</span
>
</span>
{/if}
</a>
</li>
{/each}
</ul>
</div>
@@ -0,0 +1,168 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Select } from 'bits-ui';
import IconCaretDown from 'virtual:icons/ph/caret-down';
import IconCheck from 'virtual:icons/ph/check';
import { resolveVisitTimeframe, type VisitTimeframe } from '@goto/analytics/daily';
let {
timeframes,
retentionMonths,
value
}: {
timeframes: VisitTimeframe[];
retentionMonths: number;
value: VisitTimeframe;
} = $props();
let labels = $derived({
'7d': '7 days',
'30d': '30 days',
'90d': '90 days',
all: `${retentionMonths} months`
});
let options = $derived(
timeframes.map((timeframe) => ({ value: timeframe, label: labels[timeframe] }))
);
async function selectTimeframe(timeframe: VisitTimeframe): Promise<void> {
if (timeframe === value) return;
const currentUrl = page.shallow?.url ?? page.url;
const next = new URL(currentUrl.href);
if (timeframe === '30d') next.searchParams.delete('period');
else next.searchParams.set('period', timeframe);
await goto(next, { state: page.state, shallow: true });
}
async function changeTimeframe(selected: string): Promise<void> {
const timeframe = resolveVisitTimeframe(timeframes, selected);
await selectTimeframe(timeframe);
}
</script>
<div class="timeframes">
<div class="buttons" role="group" aria-label="Timeframe">
{#each options as option (option.value)}
<button
type="button"
class="ui-button"
aria-pressed={value === option.value}
onclick={() => selectTimeframe(option.value)}>{option.label}</button
>
{/each}
</div>
<div class="mobile-select">
<Select.Root type="single" {value} items={options} onValueChange={changeTimeframe}>
<Select.Trigger aria-label={`Timeframe: ${labels[value]}`}>
{#snippet child({ props })}
<button {...props} type="button" class="ui-input timeframe-trigger">
<span>{labels[value]}</span>
<IconCaretDown aria-hidden="true" focusable="false" />
</button>
{/snippet}
</Select.Trigger>
<Select.Portal>
<Select.Content aria-label="Timeframe" align="end" sideOffset={4}>
{#snippet child({ props, wrapperProps })}
<div {...wrapperProps}>
<div {...props} class="timeframe-menu">
{#each options as option (option.value)}
<Select.Item value={option.value} label={option.label}>
{#snippet child({ props: itemProps, selected })}
<div {...itemProps} class="timeframe-option">
<span>{option.label}</span>
{#if selected}<IconCheck aria-hidden="true" focusable="false" />{/if}
</div>
{/snippet}
</Select.Item>
{/each}
</div>
</div>
{/snippet}
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
</div>
<style>
.timeframes {
margin-inline-start: auto;
display: flex;
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.mobile-select {
display: none;
}
.timeframe-trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
width: 8rem;
min-width: 8rem;
min-height: 2.5rem;
padding: var(--space-1) var(--space-4);
font-size: var(--font-size-sm);
}
.timeframe-menu {
visibility: hidden;
z-index: 100;
width: var(--bits-select-anchor-width);
max-height: var(--bits-select-content-available-height);
overflow-y: auto;
padding: var(--space-1);
font-size: var(--font-size-sm);
background: var(--color-surface-card);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-control);
box-shadow: var(--shadow-card);
}
.timeframe-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
min-height: 2.25rem;
padding: var(--space-1) var(--space-4);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
}
.timeframe-option[data-highlighted]:not([data-selected]) {
background: var(--color-surface-muted);
}
.timeframe-option[data-selected],
button[aria-pressed='true'],
button[aria-pressed='true']:hover {
color: var(--color-on-primary);
background: var(--color-primary-fill);
box-shadow: none;
}
@media (max-width: 640px) {
.buttons {
display: none;
}
.mobile-select {
display: block;
}
.timeframe-menu {
visibility: visible;
}
}
</style>
@@ -1,10 +1,12 @@
import { expect, test } from 'vitest';
import { formatUpdated, formatRetention } from './format.js';
import { formatUpdated, formatUpdatedCompact, formatRetention } from './format.js';
test('formats the snapshot update time in UTC', () => {
const updatedAt = Date.parse('2026-11-06T22:03:00Z');
expect(formatUpdated(updatedAt)).toBe('Updated Nov 6, 2026, 10:03 PM UTC');
expect(formatUpdated(null)).toBe('');
expect(formatUpdatedCompact(updatedAt)).toBe('Updated 11/6/26, 22:03 UTC');
expect(formatUpdatedCompact(null)).toBe('');
});
test('shows the configured retention policy rather than an elapsed day count', () => {
@@ -12,6 +12,17 @@ export function formatUpdated(value: number | null): string {
return `Updated ${formatter.format(value)} UTC`;
}
export function formatUpdatedCompact(value: number | null): string {
if (value === null) return '';
const formatter = new Intl.DateTimeFormat('en', {
dateStyle: 'short',
timeStyle: 'short',
hourCycle: 'h23',
timeZone: 'UTC'
});
return `Updated ${formatter.format(value)} UTC`;
}
export function formatRetention(months: number): string {
return months === 0 ? 'Off' : `${months} months`;
}
+41 -9
View File
@@ -13,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 { readOverviewDetails, readVisitSummary } from './visit-reports.js';
import { readOverviewDetails, readDomainDetails, 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-'));
@@ -50,11 +50,14 @@ test('reads prepared output and hides snapshots from a different retention polic
const domain = 'go.example.test';
const domainScope = { kind: 'domain', domain } as const;
const domainDetails: VisitDetails = { ...details, scope: domainScope };
const linkScope = { kind: 'link', domain, path: '/docs' } as const;
const linkDetails: VisitDetails = { ...details, scope: linkScope };
try {
const db = await openDb(configuration.database.path);
try {
await runMigrations(db);
expect(await readOverviewDetails(configuration, [domain])).toBeNull();
expect(await readDomainDetails(configuration, domain, ['/docs'])).toBeNull();
await publishVisitReports(
db,
encodeVisitReports([
@@ -69,6 +72,10 @@ test('reads prepared output and hides snapshots from a different retention polic
links: []
},
details: domainDetails
},
{
summary: { version: 1, scope: linkScope, period, totals },
details: linkDetails
}
])
);
@@ -83,9 +90,16 @@ test('reads prepared output and hides snapshots from a different retention polic
domains: [{ domain, details: domainDetails }]
});
expect(close).toHaveBeenCalledTimes(1);
expect(await readDomainDetails(configuration, domain, ['/docs', '/new'])).toEqual({
domain: domainDetails,
links: [{ path: '/docs', details: linkDetails }]
});
expect(close).toHaveBeenCalledTimes(2);
configuration.analytics.retention_months = 9;
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
expect(close).toHaveBeenCalledTimes(2);
expect(close).toHaveBeenCalledTimes(3);
expect(await readDomainDetails(configuration, domain, ['/docs'])).toBeNull();
expect(close).toHaveBeenCalledTimes(4);
} finally {
close.mockRestore();
}
@@ -111,13 +125,22 @@ test('invalid prepared output is unavailable, sanitized and closed', async () =>
await runMigrations(db);
await db
.insertInto('analytics_reports')
.values({
scope_kind: 'overview',
domain: null,
path: null,
summary_json: '{"private":"not-for-browser"}',
details_json: '{}'
})
.values([
{
scope_kind: 'overview',
domain: null,
path: null,
summary_json: '{"private":"not-for-browser"}',
details_json: '{}'
},
{
scope_kind: 'domain',
domain: 'go.example.test',
path: null,
summary_json: '{"private":"not-for-browser"}',
details_json: '{}'
}
])
.execute();
} finally {
await db.destroy();
@@ -127,6 +150,12 @@ test('invalid prepared output is unavailable, sanitized and closed', async () =>
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
expect(close).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledExactlyOnceWith('Prepared visit reports unavailable');
expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull();
expect(close).toHaveBeenCalledTimes(2);
expect(warning.mock.calls).toEqual([
['Prepared visit reports unavailable'],
['Prepared visit reports unavailable']
]);
} finally {
close.mockRestore();
}
@@ -150,7 +179,9 @@ test('enabled collection fails safely without creating missing storage', async (
};
expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull();
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull();
expect(warning.mock.calls).toEqual([
['Prepared visit reports unavailable'],
['Prepared visit reports unavailable'],
['Prepared visit reports unavailable']
]);
@@ -170,6 +201,7 @@ test('disabled collection does not attempt to open missing storage', async () =>
};
expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull();
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull();
expect(warning).not.toHaveBeenCalled();
} finally {
warning.mockRestore();
+24 -1
View File
@@ -3,8 +3,10 @@ import type { GotoConfiguration } from '@goto/config';
import { openDb } from '@goto/db';
import {
readOverviewVisitDetails,
readDomainVisitDetails,
readVisitSummary as readPreparedSummary,
type OverviewVisitDetails
type OverviewVisitDetails,
type DomainVisitDetails
} from '@goto/db/visit-reports';
import type { Immutable } from './snapshot.js';
@@ -30,6 +32,27 @@ export async function readOverviewDetails(
}
}
export async function readDomainDetails(
configuration: ReportConfiguration,
domain: string,
currentPaths: string[]
): Promise<DomainVisitDetails | 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 readDomainVisitDetails(db, domain, currentPaths);
if (!report || report.domain.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
+114 -51
View File
@@ -24,10 +24,22 @@
flex-direction: column;
gap: var(--space-1);
}
.admin-context {
font-size: var(--font-size-base);
overflow-wrap: anywhere;
}
.admin-context nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.admin-updated {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.admin-updated-compact {
display: none;
}
.admin-heading h1 {
font-size: var(--font-size-heading);
line-height: var(--line-height-heading);
@@ -52,17 +64,6 @@
.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);
@@ -87,62 +88,76 @@
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-card);
}
.admin-table-scroll {
overflow-x: auto;
.admin-inventory {
overflow: hidden;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-card);
background: var(--color-surface-card);
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
.admin-table th,
.admin-table td {
.admin-domain-inventory {
--inventory-columns: minmax(0, 1fr) auto;
}
.admin-domain-inventory.with-visits {
--inventory-columns: minmax(0, 1fr) auto auto;
}
.admin-link-inventory {
--inventory-columns: minmax(0, 1.2fr) minmax(0, 3fr) minmax(0, 1fr);
}
.admin-link-inventory.with-visits {
--inventory-columns: minmax(0, 1.2fr) minmax(0, 3fr) minmax(0, 1fr) minmax(5rem, 0.8fr);
}
.admin-inventory-heading,
.admin-inventory-row {
display: grid;
grid-template-columns: var(--inventory-columns);
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
text-align: start;
vertical-align: top;
}
.admin-inventory-heading {
background: var(--color-surface-muted);
font-weight: 600;
}
.admin-inventory-heading > :last-child {
text-align: end;
}
.admin-inventory-list {
list-style: none;
margin: 0;
padding: 0;
}
.admin-inventory-row {
position: relative;
border-top: var(--border-width) solid var(--color-border-subtle);
}
.admin-inventory-row > * {
min-width: 0;
overflow-wrap: anywhere;
}
.admin-table thead {
.admin-inventory-row:hover,
.admin-inventory-row:focus-within {
background: var(--color-surface-muted);
}
.admin-link-table {
table-layout: fixed;
min-width: 42rem;
.admin-domain-inventory .admin-inventory-secondary,
.admin-inventory-count {
display: inline-flex;
gap: var(--space-1);
justify-self: end;
}
.admin-link-table thead th:nth-child(1) {
width: 18%;
.admin-inventory-state {
justify-self: end;
}
.admin-link-table thead th:nth-child(2) {
width: 46%;
.admin-inventory-label {
color: var(--color-text-muted);
}
.admin-link-table thead th:nth-child(3) {
width: 20%;
.admin-inventory .admin-inventory-link {
color: var(--color-text);
text-decoration: none;
}
.admin-link-table thead th:nth-child(4) {
width: 16%;
}
.admin-table tbody tr {
position: relative;
}
.admin-table tbody tr:hover,
.admin-table tbody tr:focus-within {
background: var(--color-surface-muted);
}
.admin-row-link::after {
content: '';
position: absolute;
inset: 0;
}
.admin-row-link:focus-visible {
outline: none;
}
.admin-row-link:focus-visible::after {
.admin-inventory-link:focus-visible {
outline: var(--outline-focus);
outline-offset: -2px;
outline-offset: calc(-1 * var(--space-1));
}
.admin-destination {
position: relative;
@@ -161,6 +176,54 @@
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.admin-title-row {
align-items: flex-start;
}
.admin-inventory-heading {
display: none;
}
.admin-inventory-row {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: var(--space-1) var(--space-4);
}
.admin-inventory-primary {
grid-column: 1;
grid-row: 1;
}
.admin-inventory-secondary {
grid-column: 1;
grid-row: 2;
}
.admin-domain-inventory .admin-inventory-secondary {
justify-self: start;
}
.admin-inventory-count {
grid-column: 2;
grid-row: 1;
}
.admin-inventory-state {
grid-column: 2;
grid-row: 2;
}
.admin-link-inventory:not(.with-visits) .admin-inventory-state {
grid-row: 1;
}
.admin-updated-full {
display: none;
}
.admin-updated-compact {
display: inline;
}
.admin-title {
flex: 1 1 10rem;
}
.admin-stat-value {
font-size: 1.5rem;
}
.admin-stat-label {
font-size: var(--font-size-sm);
}
.admin-cards-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1,11 +1,4 @@
import type { VisitDetails } from '@goto/analytics';
import {
availableVisitTimeframes,
sumValidVisits,
visitWindow,
type VisitTimeframe,
type VisitWindow
} from '@goto/analytics/daily';
import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily';
import { readOverviewDetails } from '#lib/server/visit-reports.js';
import type { PageServerLoad } from './$types';
@@ -29,16 +22,16 @@ export const load = (async ({ locals }) => {
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);
const countsByTimeframe = countVisitWindows(daily, windows);
return {
domain,
linkCount: Object.keys(prepared.links).length,
visitsByTimeframe,
countsByTimeframe,
href: `/admin/domains/${encodeURIComponent(domain)}`
};
});
const asOf = overview ? Date.parse(overview.period.asOf) : null;
const visitsByTimeframe = countVisitWindows(overview?.daily, windows);
const countsByTimeframe = countVisitWindows(overview?.daily, windows);
const chart = overview ? { daily: overview.daily, period: overview.period } : null;
return {
collectionEnabled,
@@ -48,20 +41,8 @@ export const load = (async ({ locals }) => {
domainCount: domains.length,
linkCount: domains.reduce((sum, row) => sum + row.linkCount, 0),
timeframes,
visitsByTimeframe,
countsByTimeframe,
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,61 +1,34 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { visitGranularity, visitWindow, type VisitTimeframe } from '@goto/analytics/daily';
import { resolveVisitTimeframe, visitGranularity, visitWindow } 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 VisitTimeframes from '#lib/components/admin/VisitTimeframes.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'))
resolveVisitTimeframe(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 });
}
let validVisits = $derived(data.countsByTimeframe[timeframe]?.validVisits ?? null);
</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>
<VisitTimeframes
timeframes={data.timeframes}
retentionMonths={data.retentionMonths}
value={timeframe}
/>
{/if}
{/snippet}
<ReportNotice collectionEnabled={data.collectionEnabled} available={data.available} />
@@ -78,27 +51,40 @@
<h2>Domains</h2>
{#if data.domains.length === 0}<p>No domains configured.</p>
{:else}
<div class="admin-table-scroll" role="region" aria-label="Domain inventory">
<table class="admin-table">
<thead
><tr
><th scope="col">Domain</th><th scope="col">Links</th>{#if data.collectionEnabled}<th
scope="col">Visits</th
>{/if}</tr
></thead
>
<tbody>
{#each data.domains as row (row.domain)}
<tr>
<th scope="row"><a class="admin-row-link" href={row.href}>{row.domain}</a></th>
<td>{formatCount(row.linkCount)}</td>
{#if data.collectionEnabled}<td
>{formatCount(row.visitsByTimeframe[timeframe] ?? null)}</td
>{/if}
</tr>
{/each}
</tbody>
</table>
<div
class="admin-inventory admin-domain-inventory"
class:with-visits={data.collectionEnabled}
role="region"
aria-label="Domain inventory"
>
<div class="admin-inventory-heading" aria-hidden="true">
<span>Domain</span><span>Links</span>{#if data.collectionEnabled}<span>Visits</span>{/if}
</div>
<ul class="admin-inventory-list">
{#each data.domains as row (row.domain)}
<li>
<a
class="admin-inventory-row admin-inventory-link"
href={`${row.href}?period=${timeframe}`}
aria-label={row.domain}
>
<span class="admin-inventory-primary">{row.domain}</span>
<span class="admin-inventory-secondary">
<span class="admin-inventory-label">Links</span>
<span class="admin-inventory-number">{formatCount(row.linkCount)}</span>
</span>
{#if data.collectionEnabled}
<span class="admin-inventory-count">
<span class="admin-inventory-label">Visits</span>
<span class="admin-inventory-number"
>{formatCount(row.countsByTimeframe[timeframe]?.validVisits ?? null)}</span
>
</span>
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}
</section>
@@ -1,32 +1,58 @@
import { error } from '@sveltejs/kit';
import type { DomainSummary, VisitTotals } from '@goto/analytics';
import {
availableVisitTimeframes,
countVisitWindows,
visitWindow,
type VisitTimeframe
} from '@goto/analytics/daily';
import { currentLinkState } from '#lib/server/link-state.js';
import { readVisitSummary } from '#lib/server/visit-reports.js';
import { readDomainDetails } from '#lib/server/visit-reports.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
export const load = (async ({ locals, params }) => {
const snapshot = locals.snapshot;
const domain = params.domain;
if (!Object.hasOwn(snapshot.domains, domain)) throw error(404, 'Domain not configured');
const prepared = snapshot.domains[domain]!;
const configuration = snapshot.configuration;
const collectionEnabled = configuration.analytics.retention_months !== 0;
let domainSummary: DomainSummary | null = null;
if (collectionEnabled) {
const scope = { kind: 'domain' as const, domain };
const summary = await readVisitSummary(configuration, scope);
if (summary && 'links' in summary) domainSummary = summary;
const retentionMonths = configuration.analytics.retention_months;
const collectionEnabled = retentionMonths !== 0;
const currentPaths = Object.keys(prepared.links);
const report = collectionEnabled
? await readDomainDetails(configuration, domain, currentPaths)
: null;
const domainDetails = report?.domain ?? null;
const timeframes = retentionMonths === 0 ? [] : availableVisitTimeframes(retentionMonths);
const windows = domainDetails
? timeframes.map((timeframe) => ({
timeframe,
window: visitWindow(domainDetails.period, timeframe)
}))
: [];
const detailsByPath = new Map(report?.links.map(({ path, details }) => [path, details]));
const countsByTimeframe = countVisitWindows(domainDetails?.daily, windows);
const baseCountsByTimeframe = countVisitWindows(detailsByPath.get('/')?.daily, windows);
const invalidClicksByTimeframe: Partial<Record<VisitTimeframe, number>> = {};
for (const { timeframe } of windows) {
const totals = countsByTimeframe[timeframe];
if (!totals) continue;
const base = baseCountsByTimeframe[timeframe];
if (!base) continue;
invalidClicksByTimeframe[timeframe] = totals.invalidVisits - base.invalidVisits;
}
const countsByPath = new Map<string, VisitTotals>();
for (const row of domainSummary?.links ?? []) countsByPath.set(row.path, row.totals);
const now = Date.now();
const links = Object.entries(prepared.links).map(([path, link]) => ({
path,
url: link.url,
state: currentLinkState(link, now),
counts: countsByPath.get(path) ?? null,
href: linkHref(domain, path)
}));
const links = Object.entries(prepared.links).map(([path, link]) => {
const daily = detailsByPath.get(path)?.daily;
const countsByTimeframe = countVisitWindows(daily, windows);
return {
path,
url: link.url,
state: currentLinkState(link, now),
countsByTimeframe,
href: linkHref(domain, path)
};
});
const root = prepared.links['/'];
let baseUrl = prepared.fallback === 404 ? null : prepared.fallback;
let baseState = 'Fallback';
@@ -34,30 +60,28 @@ export const load: PageServerLoad = async ({ locals, params }) => {
baseUrl = root.url;
baseState = 'Active';
}
let invalidClicks: number | null = null;
if (domainSummary) {
invalidClicks = domainSummary.totals.invalidVisits - domainSummary.base.invalidVisits;
}
const asOf = domainSummary ? Date.parse(domainSummary.period.asOf) : null;
const asOf = domainDetails ? Date.parse(domainDetails.period.asOf) : null;
const chart = domainDetails ? { daily: domainDetails.daily, period: domainDetails.period } : null;
return {
domain,
retentionMonths: configuration.analytics.retention_months,
retentionMonths,
collectionEnabled,
available: domainSummary !== null,
available: domainDetails !== null,
asOf,
linkCount: links.length,
totals: domainSummary?.totals ?? null,
invalidClicks,
timeframes,
countsByTimeframe,
invalidClicksByTimeframe,
chart,
base: {
url: baseUrl,
state: baseState,
counts: domainSummary?.base ?? null,
countsByTimeframe: baseCountsByTimeframe,
href: linkHref(domain, '/')
},
links
};
};
}) satisfies PageServerLoad;
function linkHref(domain: string, path: string): string {
const base = `/admin/domains/${encodeURIComponent(domain)}/links`;
@@ -1,71 +1,81 @@
<script lang="ts">
import { page } from '$app/state';
import { resolveVisitTimeframe, visitGranularity, visitWindow } 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 { formatCount, formatRetention } from '#lib/components/admin/format.js';
import VisitsChart from '#lib/components/admin/VisitsChart.svelte';
import VisitTimeframes from '#lib/components/admin/VisitTimeframes.svelte';
import LinkInventory from '#lib/components/admin/LinkInventory.svelte';
import { 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(
resolveVisitTimeframe(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.countsByTimeframe[timeframe]?.validVisits ?? null);
let invalidClicks = $derived(data.invalidClicksByTimeframe[timeframe] ?? null);
let baseLinks = $derived([{ path: '/', ...data.base }]);
</script>
<AdminPage siteTitle={data.title} title={data.domain} updatedAt={data.asOf} backHref="/admin">
<AdminPage
siteTitle={data.title}
title="Domain"
documentTitle={`Domain — ${data.domain}`}
updatedAt={data.asOf}
overviewHref={`/admin?period=${timeframe}`}
>
{#snippet context()}{data.domain}{/snippet}
{#snippet actions()}
{#if data.available && data.timeframes.length > 0}
<VisitTimeframes
timeframes={data.timeframes}
retentionMonths={data.retentionMonths}
value={timeframe}
/>
{/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="Links" value={data.linkCount} />
<StatCard label="Valid visits" value={data.totals?.validVisits ?? null} />
<StatCard label="Invalid clicks" value={data.invalidClicks} />
<StatCard label="Retention" value={formatRetention(data.retentionMonths)} />
{#if data.collectionEnabled}
<StatCard label="Valid visits" value={validVisits} />
<StatCard label="Invalid clicks" value={invalidClicks} />
<StatCard label="Retention" value={formatRetention(data.retentionMonths)} />
{/if}
</div>
{#if data.chart && window}
<VisitsChart daily={data.chart.daily} {window} {granularity} />
{/if}
<section>
<h2>Base URL</h2>
<div class="admin-table-scroll" role="region" aria-label={`Base URL on ${data.domain}`}>
<table class="admin-table admin-link-table">
<thead
><tr
><th scope="col">Path</th><th scope="col">Destination</th><th scope="col">State</th><th
scope="col">Visits</th
></tr
></thead
>
<tbody>
<tr>
<th scope="row"><a class="admin-row-link" href={data.base.href}><code>/</code></a></th>
<td
>{#if data.base.url}<a class="admin-destination" href={data.base.url}
>{data.base.url}</a
>{:else}404 Not found{/if}</td
>
<td>{data.base.state}</td>
<td>{formatCount(data.base.counts?.validVisits ?? null)}</td>
</tr>
</tbody>
</table>
</div>
<LinkInventory
label={`Base URL on ${data.domain}`}
links={baseLinks}
{timeframe}
collectionEnabled={data.collectionEnabled}
/>
</section>
<section>
<h2>Links</h2>
{#if data.links.length === 0}<p>No links configured.</p>
{:else}
<div class="admin-table-scroll" role="region" aria-label={`Links on ${data.domain}`}>
<table class="admin-table admin-link-table">
<thead
><tr
><th scope="col">Path</th><th scope="col">Destination</th><th scope="col">State</th
><th scope="col">Visits</th></tr
></thead
>
<tbody>
{#each data.links as link (link.path)}
<tr>
<th scope="row"
><a class="admin-row-link" href={link.href}><code>{link.path}</code></a></th
>
<td><a class="admin-destination" href={link.url}>{link.url}</a></td>
<td>{link.state}</td><td>{formatCount(link.counts?.validVisits ?? null)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<LinkInventory
label={`Links on ${data.domain}`}
links={data.links}
{timeframe}
collectionEnabled={data.collectionEnabled}
/>
{/if}
</section>
</AdminPage>
@@ -9,11 +9,15 @@
<AdminPage
siteTitle={data.title}
title={data.path}
title="Link"
documentTitle={`Link — ${data.domain}${data.path}`}
updatedAt={data.asOf}
backHref={data.domainHref}
backLabel={data.domain}
>
{#snippet context()}
<nav aria-label="Breadcrumb">
<a href={data.domainHref}>{data.domain}</a> <span>{data.path}</span>
</nav>
{/snippet}
<ReportNotice collectionEnabled={data.collectionEnabled} available={data.available} />
<div class="admin-cards">
<StatCard label="Valid visits" value={data.totals?.validVisits ?? null} />
@@ -1,15 +1,17 @@
import { validateConfig } from '@goto/config';
import type { DomainSummary } from '@goto/analytics';
import { createVisitPeriod, visitDays, 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 { readDomainDetails } 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', () => ({ readDomainDetails: vi.fn() }));
beforeEach(() => vi.resetAllMocks());
const domain = 'go.example.test';
function request(host = domain, rootUrl?: string) {
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z'));
function request(host = domain, rootUrl?: string, retentionMonths = 3) {
const links: Record<string, { url: string; enabled?: boolean }> = {
'/docs': { url: 'https://example.test/docs' },
'/a/b': { url: 'https://example.test/nested' },
@@ -19,6 +21,7 @@ function request(host = domain, rootUrl?: string) {
const prepared = validateConfig(
{
auth: { credentials: { username: 'test', password: 'domain-test-password' } },
analytics: { retention_months: retentionMonths },
domains: { [domain]: { links } }
},
{ env: {} }
@@ -28,83 +31,175 @@ function request(host = domain, rootUrl?: string) {
params: { domain: host }
} as Parameters<typeof load>[0];
}
const docs = { validVisits: 2, invalidVisits: 1 };
const root = { validVisits: 0, invalidVisits: 1 };
const empty = { validVisits: 0, invalidVisits: 0 };
const summary: DomainSummary = {
version: 1,
scope: { kind: 'domain', domain },
period: {
from: '2026-03-15T12:00:00.000Z',
asOf: '2026-06-15T12:00:00.000Z',
timezone: 'UTC',
retentionMonths: 3
},
totals: { validVisits: 3, invalidVisits: 3 },
base: root,
function details(scope: ReportScope, counts: Record<string, [number, number]>): VisitDetails {
const daily = visitDays(period).map((day) => {
const [validVisits, invalidVisits] = counts[day.start.slice(0, 10)] ?? [0, 0];
return { ...day, validVisits, invalidVisits };
});
const totals = {
validVisits: daily.reduce((sum, day) => sum + day.validVisits, 0),
invalidVisits: daily.reduce((sum, day) => sum + day.invalidVisits, 0)
};
return {
version: 1,
scope,
period,
daily,
browsers: [{ family: 'Other', ...totals }],
operatingSystems: [{ family: 'Other', ...totals }]
};
}
const domainDetails = details(
{ kind: 'domain', domain },
{
'2026-03-15': [3, 2],
'2026-05-16': [5, 4],
'2026-06-08': [2, 3],
'2026-06-09': [1, 2],
'2026-06-15': [4, 3]
}
);
const baseDetails = details(
{ kind: 'link', domain, path: '/' },
{
'2026-03-15': [3, 1],
'2026-06-08': [0, 1],
'2026-06-09': [0, 1],
'2026-06-15': [0, 1]
}
);
const docsDetails = details(
{ kind: 'link', domain, path: '/docs' },
{
'2026-05-16': [5, 1],
'2026-06-09': [1, 0],
'2026-06-15': [2, 0]
}
);
const emptyDetails = details({ kind: 'link', domain, path: '/a%2Fb' }, {});
const report = {
domain: domainDetails,
links: [
{ path: '/docs', totals: docs },
{ path: '/', totals: root },
{ path: '/a%2Fb', totals: empty }
{ path: '/', details: baseDetails },
{ path: '/docs', details: docsDetails },
{ path: '/a%2Fb', details: emptyDetails }
]
};
const baseCounts = {
'7d': { validVisits: 0, invalidVisits: 2 },
'30d': { validVisits: 0, invalidVisits: 3 },
all: { validVisits: 3, invalidVisits: 4 }
};
test('reads only domain-wide traffic and links to dedicated path routes', async () => {
vi.mocked(readVisitSummary).mockResolvedValue(summary);
test('projects consistent domain, base and link windows without exposing link series or families', async () => {
vi.mocked(readDomainDetails).mockResolvedValue(report);
const data = await load(request());
if (!data) throw new Error('Missing page data');
expect(readVisitSummary).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
kind: 'domain',
domain
});
expect(data.totals).toEqual(summary.totals);
expect(data.invalidClicks).toBe(2);
expect(data.linkCount).toBe(3);
expect(data.retentionMonths).toBe(3);
expect(data.links).toMatchObject([
{ href: '/admin/domains/go.example.test/links/docs', counts: docs },
{ href: '/admin/domains/go.example.test/links/a/b' },
{ href: '/admin/domains/go.example.test/links/a%252Fb', counts: empty, state: 'Disabled' }
expect(readDomainDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), domain, [
'/docs',
'/a/b',
'/a%2Fb'
]);
expect(data.base.href).toBe('/admin/domains/go.example.test/links');
expect(data.base.url).toBeNull();
expect(data.base.state).toBe('Fallback');
expect(data.base.counts).toEqual(root);
expect(data.asOf).toBe(Date.parse(summary.period.asOf));
expect(data).toEqual({
domain,
retentionMonths: 3,
collectionEnabled: true,
available: true,
asOf: Date.parse(period.asOf),
linkCount: 3,
timeframes: ['7d', '30d', 'all'],
countsByTimeframe: {
'7d': { validVisits: 5, invalidVisits: 5 },
'30d': { validVisits: 7, invalidVisits: 8 },
all: { validVisits: 15, invalidVisits: 14 }
},
invalidClicksByTimeframe: { '7d': 3, '30d': 5, all: 10 },
chart: { daily: domainDetails.daily, period },
base: {
url: null,
state: 'Fallback',
countsByTimeframe: baseCounts,
href: '/admin/domains/go.example.test/links'
},
links: [
{
path: '/docs',
url: 'https://example.test/docs',
state: 'Active',
countsByTimeframe: {
'7d': { validVisits: 3, invalidVisits: 0 },
'30d': { validVisits: 3, invalidVisits: 0 },
all: { validVisits: 8, invalidVisits: 1 }
},
href: '/admin/domains/go.example.test/links/docs'
},
{
path: '/a/b',
url: 'https://example.test/nested',
state: 'Active',
countsByTimeframe: {},
href: '/admin/domains/go.example.test/links/a/b'
},
{
path: '/a%2Fb',
url: 'https://example.test/percent',
state: 'Disabled',
countsByTimeframe: {
'7d': { validVisits: 0, invalidVisits: 0 },
'30d': { validVisits: 0, invalidVisits: 0 },
all: { validVisits: 0, invalidVisits: 0 }
},
href: '/admin/domains/go.example.test/links/a%252Fb'
}
]
});
});
test('shows an active base destination without reclassifying historical outcomes', async () => {
vi.mocked(readVisitSummary).mockResolvedValue(summary);
test('shows an active root destination without reclassifying retained outcomes or subtracting valid root traffic', async () => {
vi.mocked(readDomainDetails).mockResolvedValue(report);
const data = await load(request(domain, 'https://example.test/start'));
if (!data) throw new Error('Missing base page data');
expect(data.base.url).toBe('https://example.test/start');
expect(data.base.state).toBe('Active');
expect(data.base.counts).toEqual(root);
expect(data.base.countsByTimeframe).toEqual(baseCounts);
expect(data.countsByTimeframe.all?.validVisits).toBe(15);
expect(data.invalidClicksByTimeframe.all).toBe(10);
expect(data.linkCount).toBe(4);
});
test('excludes invalid base traffic without subtracting valid base redirects', async () => {
const mixedRoot = { validVisits: 3, invalidVisits: 2 };
const mixedSummary: DomainSummary = {
...summary,
totals: { validVisits: 6, invalidVisits: 4 },
base: mixedRoot,
links: summary.links.map((row) => (row.path === '/' ? { ...row, totals: mixedRoot } : row))
};
vi.mocked(readVisitSummary).mockResolvedValue(mixedSummary);
test('missing base details leave base and non-base invalid counts unavailable, not zero', async () => {
vi.mocked(readDomainDetails).mockResolvedValue({
domain: domainDetails,
links: [{ path: '/docs', details: docsDetails }]
});
const data = await load(request());
if (!data) throw new Error('Missing domain page data');
expect(data.invalidClicks).toBe(2);
expect(data.totals?.validVisits).toBe(6);
expect(data.available).toBe(true);
expect(data.base.countsByTimeframe).toEqual({});
expect(data.invalidClicksByTimeframe).toEqual({});
expect(data.countsByTimeframe.all?.validVisits).toBe(15);
});
test('rejects unknown domains before reading and preserves unavailable rather than zero', async () => {
test('rejects unknown domains before reading and keeps unavailable inventory usable', async () => {
await expect(load(request('unknown.test'))).rejects.toMatchObject({ status: 404 });
expect(readVisitSummary).not.toHaveBeenCalled();
vi.mocked(readVisitSummary).mockResolvedValue(null);
expect(readDomainDetails).not.toHaveBeenCalled();
vi.mocked(readDomainDetails).mockResolvedValue(null);
const data = await load(request());
if (!data) throw new Error('Missing unavailable page');
expect(data.available).toBe(false);
expect(data.totals).toBeNull();
expect(data.invalidClicks).toBeNull();
expect(data.links[0]?.counts).toBeNull();
expect(data.chart).toBeNull();
expect(data.countsByTimeframe).toEqual({});
expect(data.invalidClicksByTimeframe).toEqual({});
expect(data.links[0]?.countsByTimeframe).toEqual({});
expect(data.linkCount).toBe(3);
});
test('disabled collection reads no reports and preserves current inventory', async () => {
const data = await load(request(domain, undefined, 0));
expect(readDomainDetails).not.toHaveBeenCalled();
expect(data.collectionEnabled).toBe(false);
expect(data.timeframes).toEqual([]);
expect(data.chart).toBeNull();
expect(data.countsByTimeframe).toEqual({});
expect(data.invalidClicksByTimeframe).toEqual({});
expect(data.base.state).toBe('Fallback');
expect(data.linkCount).toBe(3);
});
@@ -106,25 +106,37 @@ test('projects compact global window counts while inventory stays current and hi
domainCount: 3,
linkCount: 4,
timeframes: ['7d', '30d', 'all'],
visitsByTimeframe: { '7d': 3, '30d': 11, all: 42 },
countsByTimeframe: {
'7d': { validVisits: 3, invalidVisits: 7 },
'30d': { validVisits: 11, invalidVisits: 30 },
all: { validVisits: 42, invalidVisits: 93 }
},
chart: { daily: overview.daily, period },
domains: [
{
domain,
linkCount: 2,
visitsByTimeframe: { '7d': 2, '30d': 5, all: 12 },
countsByTimeframe: {
'7d': { validVisits: 2, invalidVisits: 7 },
'30d': { validVisits: 5, invalidVisits: 30 },
all: { validVisits: 12, invalidVisits: 93 }
},
href: '/admin/domains/go.example.test'
},
{
domain: emptyDomain,
linkCount: 1,
visitsByTimeframe: { '7d': 0, '30d': 0, all: 0 },
countsByTimeframe: {
'7d': { validVisits: 0, invalidVisits: 7 },
'30d': { validVisits: 0, invalidVisits: 30 },
all: { validVisits: 0, invalidVisits: 93 }
},
href: '/admin/domains/empty.example.test'
},
{
domain: newDomain,
linkCount: 1,
visitsByTimeframe: {},
countsByTimeframe: {},
href: '/admin/domains/new.example.test'
}
]
@@ -143,8 +155,13 @@ test.each([
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);
expect(data.countsByTimeframe).toEqual({
'7d': { validVisits: 7, invalidVisits: 7 },
'30d': { validVisits: 30, invalidVisits: 30 },
'90d': { validVisits: 90, invalidVisits: 90 },
all: { validVisits: retainedDays, invalidVisits: retainedDays }
});
expect(data.domains.every((row) => Object.keys(row.countsByTimeframe).length === 0)).toBe(true);
}
);
@@ -159,8 +176,8 @@ test('missing preparation keeps inventory usable without substituting zero count
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([{}, {}, {}]);
expect(data.countsByTimeframe).toEqual({});
expect(data.domains.map((row) => row.countsByTimeframe)).toEqual([{}, {}, {}]);
});
test('disabled collection reads no analytics and exposes only inventory with empty counts', async () => {
@@ -171,8 +188,8 @@ test('disabled collection reads no analytics and exposes only inventory with emp
expect(data.domainCount).toBe(3);
expect(data.linkCount).toBe(4);
expect(data.timeframes).toEqual([]);
expect(data.visitsByTimeframe).toEqual({});
expect(data.countsByTimeframe).toEqual({});
expect(data.chart).toBeNull();
expect(data.domains.map((row) => row.visitsByTimeframe)).toEqual([{}, {}, {}]);
expect(data.domains.map((row) => row.countsByTimeframe)).toEqual([{}, {}, {}]);
expect(readOverviewDetails).not.toHaveBeenCalled();
});
+58 -7
View File
@@ -1,10 +1,13 @@
import { expect, test } from 'vitest';
import {
availableVisitTimeframes,
countVisitWindows,
groupVisitDays,
sumValidVisits,
resolveVisitTimeframe,
sumVisitTotals,
visitGranularity,
visitWindow
visitWindow,
type VisitTimeframe
} from './daily.js';
import { visitDays } from './period.js';
import type { ReportPeriod, VisitDetails } from './reports.js';
@@ -34,6 +37,17 @@ test('timeframe choices and automatic granularity follow retention', () => {
expect(visitGranularity('all', 12)).toBe('month');
});
test('requested timeframes must be available and otherwise default to thirty days', () => {
const available = availableVisitTimeframes(3);
expect(resolveVisitTimeframe(available, '7d')).toBe('7d');
expect(resolveVisitTimeframe(available, 'all')).toBe('all');
expect(resolveVisitTimeframe(availableVisitTimeframes(9), '90d')).toBe('90d');
expect(resolveVisitTimeframe(available, '90d')).toBe('30d');
expect(resolveVisitTimeframe(available, 'unknown')).toBe('30d');
expect(resolveVisitTimeframe(available, null)).toBe('30d');
});
test('day presets include the latest UTC calendar day rather than rolling hours', () => {
const period: ReportPeriod = {
from: '2023-12-01T12:00:00.000Z',
@@ -71,7 +85,7 @@ test('full windows preserve partial retention boundaries and presets cannot prec
expect(window).toEqual({ from: period.from, asOf: period.asOf });
expect(visitWindow(period, '7d')).toEqual(window);
expect(sumValidVisits(daily, window)).toBe(9);
expect(sumVisitTotals(daily, window)).toEqual({ validVisits: 9, invalidVisits: 2 });
expect(groupVisitDays(daily, window, 'day')).toEqual(daily);
expect(period).toEqual(original);
});
@@ -104,11 +118,48 @@ test('selected days are filtered before grouping and share the headline total wi
invalidVisits: 2
}
]);
expect(sumValidVisits(daily, window)).toBe(9);
expect(sumVisitTotals(daily, window)).toEqual({ validVisits: 9, invalidVisits: 53 });
expect(weeks.reduce((total, week) => total + week.validVisits, 0)).toBe(9);
expect(daily).toEqual(original);
});
test('window counts project both outcomes for each requested timeframe', () => {
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 timeframes: VisitTimeframe[] = ['7d', 'all'];
const windows = timeframes.map((timeframe) => ({
timeframe,
window: visitWindow(period, timeframe)
}));
expect(countVisitWindows(daily, windows)).toEqual({
'7d': { validVisits: 9, invalidVisits: 53 },
all: { validVisits: 109, invalidVisits: 153 }
});
});
test('missing daily reports stay absent while empty and zero-filled reports give window zeros', () => {
const { period, daily } = dailyReport('2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z');
const timeframes = availableVisitTimeframes(3);
const windows = timeframes.map((timeframe) => ({
timeframe,
window: visitWindow(period, timeframe)
}));
const zeros = {
'7d': { validVisits: 0, invalidVisits: 0 },
'30d': { validVisits: 0, invalidVisits: 0 },
all: { validVisits: 0, invalidVisits: 0 }
};
expect(countVisitWindows(undefined, windows)).toEqual({});
expect(countVisitWindows([], windows)).toEqual(zeros);
expect(countVisitWindows(daily, windows)).toEqual(zeros);
});
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],
@@ -118,7 +169,7 @@ test('days touching but outside the view are excluded from groups and totals', (
});
const window = { from: '2024-02-28T00:00:00.000Z', asOf: '2024-03-01T00:00:00.000Z' };
expect(sumValidVisits(daily, window)).toBe(5);
expect(sumVisitTotals(daily, window)).toEqual({ validVisits: 5, invalidVisits: 3 });
expect(groupVisitDays(daily, window, 'day')).toEqual(daily.slice(1, 3));
});
@@ -239,7 +290,7 @@ test('exact calendar boundaries are complete and do not add a following bucket',
invalidVisits: 0
}
]);
expect(sumValidVisits(daily, period)).toBe(0);
expect(sumValidVisits([], period)).toBe(0);
expect(sumVisitTotals(daily, period)).toEqual({ validVisits: 0, invalidVisits: 0 });
expect(sumVisitTotals([], period)).toEqual({ validVisits: 0, invalidVisits: 0 });
expect(groupVisitDays([], period, 'month')).toEqual([]);
});
+30 -5
View File
@@ -1,6 +1,7 @@
import type { ReportPeriod, VisitDetails } from './reports.js';
import type { ReportPeriod, VisitDetails, VisitTotals } from './reports.js';
export type VisitTimeframe = '7d' | '30d' | '90d' | 'all';
export type VisitCountsByTimeframe = Partial<Record<VisitTimeframe, VisitTotals>>;
export type VisitWindow = { from: string; asOf: string };
export type VisitGranularity = 'day' | 'week' | 'month';
@@ -10,6 +11,16 @@ export function availableVisitTimeframes(months: 3 | 9 | 12): VisitTimeframe[] {
return months === 3 ? ['7d', '30d', 'all'] : ['7d', '30d', '90d', 'all'];
}
export function resolveVisitTimeframe(
available: VisitTimeframe[],
requested: string | null
): VisitTimeframe {
for (const timeframe of available) {
if (timeframe === requested) return timeframe;
}
return '30d';
}
export function visitWindow(period: ReportPeriod, timeframe: VisitTimeframe): VisitWindow {
if (timeframe === 'all') {
return { from: period.from, asOf: period.asOf };
@@ -25,16 +36,30 @@ export function visitWindow(period: ReportPeriod, timeframe: VisitTimeframe): Vi
};
}
export function sumValidVisits(daily: VisitDetails['daily'], window: VisitWindow): number {
export function sumVisitTotals(daily: VisitDetails['daily'], window: VisitWindow): VisitTotals {
const from = Date.parse(window.from);
const asOf = Date.parse(window.asOf);
let total = 0;
const totals: VisitTotals = { validVisits: 0, invalidVisits: 0 };
for (const day of daily) {
if (intersectsWindow(day, from, asOf)) {
total += day.validVisits;
totals.validVisits += day.validVisits;
totals.invalidVisits += day.invalidVisits;
}
}
return total;
return totals;
}
export function countVisitWindows(
daily: VisitDetails['daily'] | undefined,
windows: Array<{ timeframe: VisitTimeframe; window: VisitWindow }>
): VisitCountsByTimeframe {
const counts: VisitCountsByTimeframe = {};
if (daily === undefined) return counts;
for (const { timeframe, window } of windows) {
counts[timeframe] = sumVisitTotals(daily, window);
}
return counts;
}
export function visitGranularity(timeframe: VisitTimeframe, months: 3 | 9 | 12): VisitGranularity {
+2 -1
View File
@@ -3,10 +3,11 @@ export {
clearVisitReports,
publishVisitReports,
readOverviewVisitDetails,
readDomainVisitDetails,
readVisitSummary,
readVisitDetails
} from './storage.js';
export type { OverviewVisitDetails } from './storage.js';
export type { OverviewVisitDetails, DomainVisitDetails } from './storage.js';
export type {
AggregatedVisitReports,
DailyVisitCounts,
@@ -17,6 +17,7 @@ import {
publishVisitReports,
readVisitDetails,
readOverviewVisitDetails,
readDomainVisitDetails,
readVisitSummary
} from './storage.js';
@@ -177,6 +178,115 @@ test('overview details reject an invalid overview independently of summary JSON'
}
});
test('domain details use one SQL snapshot with current paths and root, without summaries', async () => {
const current = preparedReport(domainScope, asOf, { validVisits: 3, invalidVisits: 1 });
const root = preparedReport(rootScope);
const docs = preparedReport({ kind: 'link', domain, path: '/docs' });
const removed = preparedReport({ kind: 'link', domain, path: '/removed' });
const otherDomain = preparedReport({ kind: 'domain', domain: 'other.example.test' });
const otherLink = preparedReport({ kind: 'link', domain: 'other.example.test', path: '/docs' });
await publishVisitReports(
db,
encodeVisitReports([
preparedReport(overviewScope),
current,
root,
docs,
removed,
otherDomain,
otherLink
])
);
await db.updateTable('analytics_reports').set({ summary_json: '{}' }).execute();
await db
.updateTable('analytics_reports')
.set({ details_json: '{}' })
.where(({ or, eb }) =>
or([
eb('scope_kind', '=', 'overview'),
eb('domain', '=', 'other.example.test'),
eb('path', '=', '/removed')
])
)
.execute();
const statements = vi.spyOn(DatabaseSync.prototype, 'prepare');
try {
expect(await readDomainVisitDetails(db, domain, ['/docs', '/docs', '/new'])).toEqual({
domain: current.details,
links: [
{ path: '/', details: root.details },
{ path: '/docs', details: docs.details }
]
});
expect(statements).toHaveBeenCalledTimes(1);
const query = statements.mock.calls[0]?.[0];
expect(query).toMatch(
/^select\s+"scope_kind",\s*"path",\s*"details_json"\s+from\s+"analytics_reports"/i
);
expect(query).not.toMatch(/summary_json|\bvisits\b|\bjoin\b/i);
} finally {
statements.mockRestore();
}
});
test('domain details require a domain report and preserve missing paths', async () => {
expect(await readDomainVisitDetails(db, domain, ['/docs'])).toBeNull();
await publishVisitReports(db, encodeVisitReports([preparedReport(rootScope)]));
expect(await readDomainVisitDetails(db, domain, [])).toBeNull();
const current = preparedReport(domainScope);
await publishVisitReports(db, encodeVisitReports([current]));
expect(await readDomainVisitDetails(db, domain, ['/docs'])).toEqual({
domain: current.details,
links: []
});
});
test('domain details validate the requested domain independently of summary JSON', async () => {
const current = preparedReport(domainScope);
await publishVisitReports(db, encodeVisitReports([current]));
await db
.updateTable('analytics_reports')
.set({
summary_json: '{}',
details_json: JSON.stringify({
...current.details,
scope: { kind: 'domain', domain: 'wrong.example.test' }
})
})
.execute();
await expect(readDomainVisitDetails(db, domain, [])).rejects.toThrow(
/^Invalid analytics report$/
);
});
test('domain details validate each selected path scope', async () => {
const root = preparedReport(rootScope);
await publishVisitReports(db, encodeVisitReports([preparedReport(domainScope), root]));
await db
.updateTable('analytics_reports')
.set({ details_json: JSON.stringify({ ...root.details, scope: domainScope }) })
.where('scope_kind', '=', 'link')
.execute();
await expect(readDomainVisitDetails(db, domain, [])).rejects.toThrow(
/^Invalid analytics report$/
);
});
test('domain details require selected paths to share the domain publication period', async () => {
await publishVisitReports(
db,
encodeVisitReports([preparedReport(domainScope), preparedReport(rootScope)])
);
await db
.updateTable('analytics_reports')
.set({ details_json: JSON.stringify(preparedReport(rootScope, asOf + 86_400_000).details) })
.where('scope_kind', '=', 'link')
.execute();
await expect(readDomainVisitDetails(db, domain, [])).rejects.toThrow(
/^Invalid analytics report$/
);
});
test('publication replaces the complete batch, including when the replacement is empty', async () => {
await publishVisitReports(
db,
+41
View File
@@ -76,6 +76,47 @@ export async function readOverviewVisitDetails(
return { overview, domains };
}
export interface DomainVisitDetails {
domain: VisitDetails;
links: Array<{ path: string; details: VisitDetails }>;
}
export async function readDomainVisitDetails(
db: Kysely<Database>,
domain: string,
currentPaths: string[]
): Promise<DomainVisitDetails | null> {
const paths = [...new Set(['/', ...currentPaths])];
const rows = await db
.selectFrom('analytics_reports')
.select(['scope_kind', 'path', 'details_json'])
.where('domain', '=', domain)
.where(({ and, or, eb }) =>
or([
and([eb('scope_kind', '=', 'domain'), eb('path', 'is', null)]),
and([eb('scope_kind', '=', 'link'), eb('path', 'in', paths)])
])
)
.execute();
const domainRow = rows.find((row) => row.scope_kind === 'domain');
if (!domainRow) return null;
const details = parseVisitDetails(domainRow.details_json, { kind: 'domain', domain });
const links: DomainVisitDetails['links'] = [];
for (const row of rows) {
if (row.scope_kind === 'domain') continue;
if (row.scope_kind !== 'link' || row.path === null) {
throw new Error('Invalid analytics report');
}
const linkDetails = parseVisitDetails(
row.details_json,
{ kind: 'link', domain, path: row.path },
details.period
);
links.push({ path: row.path, details: linkDetails });
}
return { domain: details, links };
}
export async function readVisitSummary(
db: Kysely<Database>,
scope: ReportScope
+120
View File
@@ -42,6 +42,9 @@ catalogs:
better-auth:
specifier: 1.7.14
version: 1.7.14
bits-ui:
specifier: 2.18.1
version: 2.18.1
chart.js:
specifier: 4.6.1
version: 4.6.1
@@ -187,6 +190,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)
bits-ui:
specifier: 'catalog:'
version: 2.18.1(@internationalized/date@3.12.4)(@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))
chart.js:
specifier: 'catalog:'
version: 4.6.1
@@ -718,6 +724,15 @@ packages:
resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -747,6 +762,9 @@ packages:
'@iconify/utils@3.1.7':
resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==}
'@internationalized/date@3.12.4':
resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1066,6 +1084,9 @@ packages:
svelte: ^5.46.4
vite: 8.0.16
'@swc/helpers@0.5.24':
resolution: {integrity: sha512-6tcXo4kA8HgGO7AVmawB57RYi/piBPYVHVcO6dXjX+SZw8GoHmi262sp4v2Ha2kNP9Cx84qei27yT+fjS1lxgg==}
'@tybys/wasm-util@0.10.4':
resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==}
@@ -1283,6 +1304,13 @@ packages:
zod:
optional: true
bits-ui@2.18.1:
resolution: {integrity: sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==}
engines: {node: '>=20'}
peerDependencies:
'@internationalized/date': ^3.8.1
svelte: ^5.33.0
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
@@ -1352,6 +1380,10 @@ packages:
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1556,6 +1588,9 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1717,6 +1752,10 @@ packages:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1895,6 +1934,15 @@ packages:
rou3@0.9.2:
resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
runed@0.35.1:
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
peerDependencies:
'@sveltejs/kit': ^2.21.0
svelte: ^5.7.0
peerDependenciesMeta:
'@sveltejs/kit':
optional: true
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -1939,6 +1987,9 @@ packages:
std-env@4.3.0:
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -1960,10 +2011,19 @@ packages:
svelte:
optional: true
svelte-toolbelt@0.10.7:
resolution: {integrity: sha512-gjuUVEEpo38UJCeI1ssE3NxG31tYENczPTcTGTXf4338qAQNdR9I1oAEbQY0KQFmbdUojNIhHuRNix7ThwFNxQ==}
engines: {node: '>=18', pnpm: '>=8.7.0'}
peerDependencies:
svelte: ^5.30.2
svelte@5.57.3:
resolution: {integrity: sha512-5mAftM3SoDV9X0zdF386F9p2pv8iKOwi/KfBS1XnBAkVCKFzVOLHUoa8iGWAt4buoicLmyWVRpuuD5wplXOQsg==}
engines: {node: '>=18'}
tabbable@6.5.1:
resolution: {integrity: sha512-dMCuwDUY6Noz3zVKLefBC4wq+aJV20yc7vWmM7uTNGwtQSFF5kX7OUIUsxuVajz/bdt5Xqdah3/0NLjth+cM5A==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2495,6 +2555,17 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/utils@0.2.12': {}
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -2523,6 +2594,10 @@ snapshots:
'@iconify/types': 2.0.0
import-meta-resolve: 4.2.0
'@internationalized/date@3.12.4':
dependencies:
'@swc/helpers': 0.5.24
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -2744,6 +2819,8 @@ snapshots:
vite: 8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)
vitefu: 1.1.3(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1))
'@swc/helpers@0.5.24': {}
'@tybys/wasm-util@0.10.4':
dependencies:
tslib: 2.8.1
@@ -2974,6 +3051,19 @@ snapshots:
optionalDependencies:
zod: 4.6.7
bits-ui@2.18.1(@internationalized/date@3.12.4)(@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)):
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/dom': 1.8.0
'@internationalized/date': 3.12.4
esm-env: 1.2.2
runed: 0.35.1(@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))
svelte: 5.57.3(@typescript-eslint/types@8.60.0)
svelte-toolbelt: 0.10.7(@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))
tabbable: 6.5.1
transitivePeerDependencies:
- '@sveltejs/kit'
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -3022,6 +3112,8 @@ snapshots:
defu@6.1.7: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
devalue@5.9.4: {}
@@ -3240,6 +3332,8 @@ snapshots:
imurmurhash@0.1.4: {}
inline-style-parser@0.2.7: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
@@ -3364,6 +3458,8 @@ snapshots:
luxon@3.7.2: {}
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -3528,6 +3624,15 @@ snapshots:
rou3@0.9.2: {}
runed@0.35.1(@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)):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.57.3(@typescript-eslint/types@8.60.0)
optionalDependencies:
'@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))
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -3560,6 +3665,10 @@ snapshots:
std-env@4.3.0: {}
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -3589,6 +3698,15 @@ snapshots:
optionalDependencies:
svelte: 5.57.3(@typescript-eslint/types@8.60.0)
svelte-toolbelt@0.10.7(@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)):
dependencies:
clsx: 2.1.1
runed: 0.35.1(@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))
style-to-object: 1.0.14
svelte: 5.57.3(@typescript-eslint/types@8.60.0)
transitivePeerDependencies:
- '@sveltejs/kit'
svelte@5.57.3(@typescript-eslint/types@8.60.0):
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -3609,6 +3727,8 @@ snapshots:
transitivePeerDependencies:
- '@typescript-eslint/types'
tabbable@6.5.1: {}
tinybench@2.9.0: {}
tinyexec@1.3.1: {}
+1
View File
@@ -15,6 +15,7 @@ catalog:
'@types/node': 24.12.4
'@vitest/coverage-v8': 4.1.7
better-auth: 1.7.14
bits-ui: 2.18.1
chart.js: 4.6.1
esbuild: 0.28.2
eslint: 10.4.1