feat(admin): add link visit charts #20
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@goto/web': minor
|
||||
'@goto/worker': minor
|
||||
'@goto/cli': minor
|
||||
---
|
||||
|
||||
Add the shared visits chart and retention-aware timeframes to individual link pages, including the base `/` path. Keep each link's visit card and chart on the same selected window, preserve that window through overview/domain/link navigation, and keep link details usable without analytics. Reuse existing prepared daily reports and shared dashboard controls. Keep desktop stat cards consistently sized across dashboards, remove repeated retention cards, and show link status in a card with domain, path and destination in the details section. Add a full-width, scrollable native data table beneath the shared chart on all dashboard pages, matching its selected periods and visit totals with sticky UTC headers and alternating row backgrounds.
|
||||
+80
-27
@@ -7,6 +7,14 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
page,
|
||||
appServer
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
const cardSizes = () =>
|
||||
page.locator('.admin-stat').evaluateAll((cards) =>
|
||||
cards.map((card) => {
|
||||
const { width, height } = card.getBoundingClientRect();
|
||||
return { width, height };
|
||||
})
|
||||
);
|
||||
const navigationWarnings: string[] = [];
|
||||
page.on('console', (message) => {
|
||||
const text = message.text();
|
||||
@@ -27,11 +35,12 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
.locator('.admin-stat-value');
|
||||
await expect(page.getByRole('region', { name: 'Domains', exact: true })).toContainText('2');
|
||||
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);
|
||||
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([
|
||||
'Domains',
|
||||
'Links',
|
||||
'Valid visits'
|
||||
]);
|
||||
const overviewCardSizes = await cardSizes();
|
||||
const heading = page.locator('.admin-heading');
|
||||
await expect(heading.locator('.admin-title > h1 + time')).toBeVisible();
|
||||
const timeframe = heading.getByRole('group', { name: 'Timeframe', exact: true });
|
||||
@@ -41,8 +50,18 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
'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).toHaveAttribute('aria-busy', 'false');
|
||||
const chartTable = trend.getByRole('table');
|
||||
await expect(chartTable).toBeVisible();
|
||||
await expect(chartTable).toHaveAccessibleName('Valid visits');
|
||||
await expect(chartTable.getByRole('columnheader')).toHaveText(['Period (UTC)', 'Valid visits']);
|
||||
await expect(chartTable.getByRole('row', { name: 'Total 2', exact: true })).toBeVisible();
|
||||
await expect(chartTable).not.toContainText('(partial)');
|
||||
await expect(trend.getByRole('region', { name: 'Visit data', exact: true })).toHaveAttribute(
|
||||
'tabindex',
|
||||
'0'
|
||||
);
|
||||
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 });
|
||||
@@ -110,7 +129,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
.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).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',
|
||||
@@ -120,8 +139,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([
|
||||
'Links',
|
||||
'Valid visits',
|
||||
'Invalid clicks',
|
||||
'Retention'
|
||||
'Invalid clicks'
|
||||
]);
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Invalid clicks', exact: true }).locator('.admin-stat-value')
|
||||
@@ -158,6 +176,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
await expect(retainedVisits).toHaveText('1');
|
||||
await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 2\./);
|
||||
expect(await cardSizes()).toEqual(overviewCardSizes);
|
||||
const historicalInvalid = linksInventory
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('link', { name: '/a/b', exact: true }) });
|
||||
@@ -176,46 +195,72 @@ test('shared credentials protect overview, domain and link pages and sign-out re
|
||||
).toHaveCount(0);
|
||||
await expect(baseInventory.getByRole('link')).toHaveCount(1);
|
||||
|
||||
const domainBreadcrumb = page
|
||||
.getByRole('navigation', { name: 'Breadcrumb', exact: true })
|
||||
.getByRole('link', { name: 'go.example.org', exact: true });
|
||||
const linkDetails = page.getByRole('region', { name: 'Link details', exact: true });
|
||||
const domainDetailsLink = linkDetails.getByRole('link', { name: 'go.example.org', exact: true });
|
||||
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).toHaveURL(`${appServer.origin}${docsPath}?period=all`);
|
||||
await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible();
|
||||
await expect(context).toHaveText('go.example.org /docs');
|
||||
await expect(context).toHaveCount(0);
|
||||
await expect(
|
||||
linkDetails.getByRole('heading', { name: 'Link details', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(linkDetails.locator('dt')).toHaveText(['Domain', 'Path', 'Destination']);
|
||||
await expect(linkDetails.locator('dd').nth(1)).toHaveText('/docs');
|
||||
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([
|
||||
'Valid visits',
|
||||
'Status'
|
||||
]);
|
||||
await expect(page.getByRole('region', { name: 'Status', exact: true })).toContainText('Active');
|
||||
expect(await cardSizes()).toEqual(overviewCardSizes.slice(0, 2));
|
||||
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(graph).toHaveAttribute('aria-busy', 'false');
|
||||
await expect(chartTable).toHaveAccessibleName('Valid visits');
|
||||
await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 1\./);
|
||||
await expect(visitsValue).toHaveText('1');
|
||||
await timeframe.getByRole('button', { name: '7 days', exact: true }).click();
|
||||
await expect(visitsValue).toHaveText('0');
|
||||
await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 0\./);
|
||||
await expect(chartTable.getByRole('row', { name: 'Total 0', exact: true })).toBeVisible();
|
||||
await timeframe.getByRole('button', { name: '3 months', exact: true }).click();
|
||||
await expect(visitsValue).toHaveText('1');
|
||||
await expect(domainDetailsLink).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/domains/go.example.org?period=all'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Links on go.example.org', exact: true })
|
||||
).toHaveCount(0);
|
||||
|
||||
await domainBreadcrumb.click();
|
||||
await domainDetailsLink.click();
|
||||
await page.getByRole('link', { name: '/a%2Fb', exact: true }).click();
|
||||
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(page).toHaveTitle('Link — go.example.org/a%2Fb — Goto');
|
||||
await expect(page).toHaveURL(
|
||||
`${appServer.origin}/admin/domains/go.example.org/links/a%252Fb?period=all`
|
||||
);
|
||||
await expect(visitsValue).toHaveText('1');
|
||||
await domainBreadcrumb.click();
|
||||
await domainDetailsLink.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).toHaveURL(
|
||||
`${appServer.origin}/admin/domains/go.example.org/links/a/b?period=all`
|
||||
);
|
||||
await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible();
|
||||
await expect(context).toHaveText('go.example.org /a/b');
|
||||
await expect(page).toHaveTitle('Link — go.example.org/a/b — Goto');
|
||||
await expect(visitsValue).toHaveText('0');
|
||||
|
||||
await domainBreadcrumb.click();
|
||||
await domainDetailsLink.click();
|
||||
await baseInventory.getByRole('link', { name: '/', exact: true }).click();
|
||||
await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links`);
|
||||
await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links?period=all`);
|
||||
await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible();
|
||||
await expect(context).toHaveText('go.example.org /');
|
||||
await expect(page).toHaveTitle('Link — go.example.org/ — Goto');
|
||||
await expect(visitsValue).toHaveText('0');
|
||||
await domainBreadcrumb.click();
|
||||
await expect(timeframe.getByRole('button', { name: '30 days', exact: true })).toHaveAttribute(
|
||||
await domainDetailsLink.click();
|
||||
await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(visitsValue).toHaveText('1');
|
||||
await expect(visitsValue).toHaveText('2');
|
||||
|
||||
const html = await page.content();
|
||||
await expect(html).not.toContain('e2e-only-password');
|
||||
@@ -265,5 +310,13 @@ test.describe('analytics disabled', () => {
|
||||
.getByRole('region', { name: 'Base URL on go.example.org', exact: true })
|
||||
.locator('.admin-inventory-heading > span')
|
||||
).toHaveText(['Path', 'Destination', 'State']);
|
||||
await page.getByRole('link', { name: '/docs', exact: true }).click();
|
||||
await expect(page.getByRole('region', { name: 'Link details', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText(['Status']);
|
||||
await expect(page.getByRole('region', { name: 'Status', exact: true })).toContainText('Active');
|
||||
await expect(page.getByRole('group', { name: 'Timeframe', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,11 @@
|
||||
<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}>
|
||||
<a
|
||||
class="admin-inventory-row admin-inventory-link"
|
||||
href={`${link.href}?period=${timeframe}`}
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { formatCount } from './format.js';
|
||||
let { label, value }: { label: string; value: number | string | null } = $props();
|
||||
let displayValue = $derived(typeof value === 'string' ? value : formatCount(value));
|
||||
</script>
|
||||
|
||||
<section class="admin-stat" aria-label={label}>
|
||||
<span class="admin-stat-value">{typeof value === 'string' ? value : formatCount(value)}</span>
|
||||
<span class="admin-stat-value" title={displayValue}>{displayValue}</span>
|
||||
<span class="admin-stat-label">{label}</span>
|
||||
</section>
|
||||
@@ -52,12 +52,12 @@
|
||||
return (grouping === 'month' ? monthFormat : dateFormat).format(new Date(start));
|
||||
}
|
||||
|
||||
function bucketTitle(bucket: VisitDetails['daily'][number]): string {
|
||||
function bucketPeriod(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`;
|
||||
return dateFormat.formatRange(from, end);
|
||||
}
|
||||
|
||||
function chartOptions(points: VisitDetails['daily']): ChartOptions<'line'> {
|
||||
@@ -98,7 +98,7 @@
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const bucket = points[items[0]?.dataIndex ?? -1];
|
||||
return bucket ? bucketTitle(bucket) : '';
|
||||
return bucket ? `${bucketPeriod(bucket)} UTC` : '';
|
||||
},
|
||||
label: (item) => `Valid visits: ${formatCount(item.parsed.y)}`,
|
||||
footer: (items) => (points[items[0]?.dataIndex ?? -1]?.partial ? 'Partial period' : '')
|
||||
@@ -192,7 +192,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="visits-chart" aria-label="Visits over time" aria-busy={!ready && !unavailable}>
|
||||
<section class="visits-chart" aria-label="Visits over time">
|
||||
<header>
|
||||
<h2>Visits</h2>
|
||||
<span class="grouping">{groupingLabels[granularity]}</span>
|
||||
@@ -201,10 +201,29 @@
|
||||
{#if unavailable}
|
||||
<p role="status">Chart unavailable.</p>
|
||||
{:else}
|
||||
<div class="plot" role="img" aria-label={summary}>
|
||||
<div class="plot" role="img" aria-label={summary} aria-busy={!ready}>
|
||||
<canvas bind:this={canvas} aria-hidden="true">{summary}</canvas>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard scrolling needs a focusable region.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Visit data" tabindex="0">
|
||||
<table aria-label="Valid visits">
|
||||
<thead>
|
||||
<tr><th scope="col">Period (UTC)</th><th scope="col">Valid visits</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each buckets as bucket (bucket.start)}
|
||||
<tr>
|
||||
<th scope="row">{bucketPeriod(bucket)}</th>
|
||||
<td>{formatCount(bucket.validVisits)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><th scope="row">Total</th><td>{formatCount(total)}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -240,6 +259,63 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
max-block-size: 18rem;
|
||||
overflow: auto;
|
||||
margin-top: var(--space-5);
|
||||
margin-inline: calc(-1 * var(--space-5));
|
||||
margin-bottom: calc(-1 * var(--space-5));
|
||||
border-radius: 0 0 var(--radius-card) var(--radius-card);
|
||||
}
|
||||
|
||||
.table-scroll:focus-visible {
|
||||
outline: var(--outline-focus);
|
||||
outline-offset: calc(-1 * var(--space-1));
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-top: 0;
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
tbody th,
|
||||
tfoot th {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
thead th:last-child {
|
||||
width: 7rem;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
td {
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chart-palette {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-surface-card);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { formatUpdated, formatUpdatedCompact, formatRetention } from './format.js';
|
||||
import { formatUpdated, formatUpdatedCompact } from './format.js';
|
||||
|
||||
test('formats the snapshot update time in UTC', () => {
|
||||
const updatedAt = Date.parse('2026-11-06T22:03:00Z');
|
||||
@@ -8,8 +8,3 @@ test('formats the snapshot update time in UTC', () => {
|
||||
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', () => {
|
||||
expect(formatRetention(3)).toBe('3 months');
|
||||
expect(formatRetention(0)).toBe('Off');
|
||||
});
|
||||
@@ -22,7 +22,3 @@ export function formatUpdatedCompact(value: number | null): string {
|
||||
});
|
||||
return `Updated ${formatter.format(value)} UTC`;
|
||||
}
|
||||
|
||||
export function formatRetention(months: number): string {
|
||||
return months === 0 ? 'Off' : `${months} months`;
|
||||
}
|
||||
@@ -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, readDomainDetails, readVisitSummary } from './visit-reports.js';
|
||||
import { readOverviewDetails, readDomainDetails, readVisitDetails } 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-'));
|
||||
@@ -58,6 +58,7 @@ test('reads prepared output and hides snapshots from a different retention polic
|
||||
await runMigrations(db);
|
||||
expect(await readOverviewDetails(configuration, [domain])).toBeNull();
|
||||
expect(await readDomainDetails(configuration, domain, ['/docs'])).toBeNull();
|
||||
expect(await readVisitDetails(configuration, linkScope)).toBeNull();
|
||||
await publishVisitReports(
|
||||
db,
|
||||
encodeVisitReports([
|
||||
@@ -82,7 +83,6 @@ test('reads prepared output and hides snapshots from a different retention polic
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
expect(await readVisitSummary(configuration, { kind: 'overview' })).toEqual(summary);
|
||||
const close = vi.spyOn(DatabaseSync.prototype, 'close');
|
||||
try {
|
||||
expect(await readOverviewDetails(configuration, [domain, 'new.example.test'])).toEqual({
|
||||
@@ -95,15 +95,20 @@ test('reads prepared output and hides snapshots from a different retention polic
|
||||
links: [{ path: '/docs', details: linkDetails }]
|
||||
});
|
||||
expect(close).toHaveBeenCalledTimes(2);
|
||||
expect(await readVisitDetails(configuration, linkScope)).toEqual(linkDetails);
|
||||
expect(close).toHaveBeenCalledTimes(3);
|
||||
expect(await readVisitDetails(configuration, { ...linkScope, path: '/missing' })).toBeNull();
|
||||
expect(close).toHaveBeenCalledTimes(4);
|
||||
configuration.analytics.retention_months = 9;
|
||||
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
|
||||
expect(close).toHaveBeenCalledTimes(3);
|
||||
expect(close).toHaveBeenCalledTimes(5);
|
||||
expect(await readDomainDetails(configuration, domain, ['/docs'])).toBeNull();
|
||||
expect(close).toHaveBeenCalledTimes(4);
|
||||
expect(close).toHaveBeenCalledTimes(6);
|
||||
expect(await readVisitDetails(configuration, linkScope)).toBeNull();
|
||||
expect(close).toHaveBeenCalledTimes(7);
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
}
|
||||
expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -139,6 +144,13 @@ test('invalid prepared output is unavailable, sanitized and closed', async () =>
|
||||
path: null,
|
||||
summary_json: '{"private":"not-for-browser"}',
|
||||
details_json: '{}'
|
||||
},
|
||||
{
|
||||
scope_kind: 'link',
|
||||
domain: 'go.example.test',
|
||||
path: '/docs',
|
||||
summary_json: '{"private":"not-for-browser"}',
|
||||
details_json: '{"private":"not-for-browser"}'
|
||||
}
|
||||
])
|
||||
.execute();
|
||||
@@ -152,7 +164,16 @@ test('invalid prepared output is unavailable, sanitized and closed', async () =>
|
||||
expect(warning).toHaveBeenCalledExactlyOnceWith('Prepared visit reports unavailable');
|
||||
expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull();
|
||||
expect(close).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
await readVisitDetails(configuration, {
|
||||
kind: 'link',
|
||||
domain: 'go.example.test',
|
||||
path: '/docs'
|
||||
})
|
||||
).toBeNull();
|
||||
expect(close).toHaveBeenCalledTimes(3);
|
||||
expect(warning.mock.calls).toEqual([
|
||||
['Prepared visit reports unavailable'],
|
||||
['Prepared visit reports unavailable'],
|
||||
['Prepared visit reports unavailable']
|
||||
]);
|
||||
@@ -177,7 +198,13 @@ test('enabled collection fails safely without creating missing storage', async (
|
||||
},
|
||||
analytics: { retention_months: 3 as const }
|
||||
};
|
||||
expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull();
|
||||
expect(
|
||||
await readVisitDetails(configuration, {
|
||||
kind: 'link',
|
||||
domain: 'go.example.test',
|
||||
path: '/docs'
|
||||
})
|
||||
).toBeNull();
|
||||
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
|
||||
expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull();
|
||||
expect(warning.mock.calls).toEqual([
|
||||
@@ -199,7 +226,13 @@ test('disabled collection does not attempt to open missing storage', async () =>
|
||||
database: { path: '/missing/not-used.sqlite', worker_path: '/missing/worker.sqlite' },
|
||||
analytics: { retention_months: 0 as const }
|
||||
};
|
||||
expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull();
|
||||
expect(
|
||||
await readVisitDetails(configuration, {
|
||||
kind: 'link',
|
||||
domain: 'go.example.test',
|
||||
path: '/docs'
|
||||
})
|
||||
).toBeNull();
|
||||
expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull();
|
||||
expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull();
|
||||
expect(warning).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ReportScope, VisitSummary } from '@goto/analytics';
|
||||
import type { ReportScope, VisitDetails } from '@goto/analytics';
|
||||
import type { GotoConfiguration } from '@goto/config';
|
||||
import { openDb } from '@goto/db';
|
||||
import {
|
||||
readOverviewVisitDetails,
|
||||
readDomainVisitDetails,
|
||||
readVisitSummary as readPreparedSummary,
|
||||
readVisitDetails as readPreparedDetails,
|
||||
type OverviewVisitDetails,
|
||||
type DomainVisitDetails
|
||||
} from '@goto/db/visit-reports';
|
||||
@@ -53,18 +53,18 @@ export async function readDomainDetails(
|
||||
}
|
||||
}
|
||||
|
||||
export async function readVisitSummary(
|
||||
export async function readVisitDetails(
|
||||
configuration: ReportConfiguration,
|
||||
scope: ReportScope
|
||||
): Promise<VisitSummary | null> {
|
||||
): Promise<VisitDetails | 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 summary = await readPreparedSummary(db, scope);
|
||||
if (!summary || summary.period.retentionMonths !== months) return null;
|
||||
return summary;
|
||||
const details = await readPreparedDetails(db, scope);
|
||||
if (!details || details.period.retentionMonths !== months) return null;
|
||||
return details;
|
||||
} catch {
|
||||
warnUnavailable();
|
||||
return null;
|
||||
|
||||
@@ -55,14 +55,8 @@
|
||||
}
|
||||
.admin-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.admin-cards-summary {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.admin-cards-inventory {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.admin-stat,
|
||||
.admin-info {
|
||||
@@ -72,11 +66,17 @@
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.admin-stat {
|
||||
min-width: 0;
|
||||
block-size: 8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.admin-stat-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-heading);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -218,13 +218,16 @@
|
||||
.admin-title {
|
||||
flex: 1 1 10rem;
|
||||
}
|
||||
.admin-stat {
|
||||
block-size: 7rem;
|
||||
}
|
||||
.admin-stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.admin-stat-label {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.admin-cards-summary {
|
||||
.admin-cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.admin-info dl {
|
||||
@@ -232,8 +235,7 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.admin-cards-summary,
|
||||
.admin-cards-inventory {
|
||||
.admin-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
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 { formatCount } from '#lib/components/admin/format.js';
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -32,16 +32,11 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
<ReportNotice collectionEnabled={data.collectionEnabled} available={data.available} />
|
||||
<div
|
||||
class="admin-cards"
|
||||
class:admin-cards-summary={data.collectionEnabled}
|
||||
class:admin-cards-inventory={!data.collectionEnabled}
|
||||
>
|
||||
<div class="admin-cards">
|
||||
<StatCard label="Domains" value={data.domainCount} />
|
||||
<StatCard label="Links" value={data.linkCount} />
|
||||
{#if data.collectionEnabled}
|
||||
<StatCard label="Valid visits" value={validVisits} />
|
||||
<StatCard label="Retention" value={formatRetention(data.retentionMonths)} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.chart && window}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
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();
|
||||
|
||||
@@ -42,16 +41,11 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
<ReportNotice collectionEnabled={data.collectionEnabled} available={data.available} />
|
||||
<div
|
||||
class="admin-cards"
|
||||
class:admin-cards-summary={data.collectionEnabled}
|
||||
class:admin-cards-inventory={!data.collectionEnabled}
|
||||
>
|
||||
<div class="admin-cards">
|
||||
<StatCard label="Links" value={data.linkCount} />
|
||||
{#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}
|
||||
|
||||
+20
-14
@@ -1,10 +1,10 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { VisitSummary } from '@goto/analytics';
|
||||
import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily';
|
||||
import { currentLinkState } from '#lib/server/link-state.js';
|
||||
import { readVisitSummary } from '#lib/server/visit-reports.js';
|
||||
import { readVisitDetails } 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');
|
||||
@@ -14,14 +14,18 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
if (path !== '/' && !Object.hasOwn(prepared.links, path)) throw error(404, 'Link not configured');
|
||||
const link = prepared.links[path];
|
||||
const configuration = snapshot.configuration;
|
||||
const collectionEnabled = configuration.analytics.retention_months !== 0;
|
||||
let linkSummary: VisitSummary | null = null;
|
||||
if (collectionEnabled) {
|
||||
const scope = { kind: 'link' as const, domain, path };
|
||||
linkSummary = await readVisitSummary(configuration, scope);
|
||||
}
|
||||
const retentionMonths = configuration.analytics.retention_months;
|
||||
const collectionEnabled = retentionMonths !== 0;
|
||||
const scope = { kind: 'link' as const, domain, path };
|
||||
const details = collectionEnabled ? await readVisitDetails(configuration, scope) : null;
|
||||
const timeframes = retentionMonths === 0 ? [] : availableVisitTimeframes(retentionMonths);
|
||||
const windows = details
|
||||
? timeframes.map((timeframe) => ({ timeframe, window: visitWindow(details.period, timeframe) }))
|
||||
: [];
|
||||
const countsByTimeframe = countVisitWindows(details?.daily, windows);
|
||||
const state = link ? currentLinkState(link, Date.now()) : 'Fallback';
|
||||
const asOf = linkSummary ? Date.parse(linkSummary.period.asOf) : null;
|
||||
const asOf = details ? Date.parse(details.period.asOf) : null;
|
||||
const chart = details ? { daily: details.daily, period: details.period } : null;
|
||||
|
||||
return {
|
||||
domain,
|
||||
@@ -30,10 +34,12 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
url: link?.url ?? null,
|
||||
fallback: prepared.fallback,
|
||||
state,
|
||||
retentionMonths: configuration.analytics.retention_months,
|
||||
retentionMonths,
|
||||
collectionEnabled,
|
||||
available: linkSummary !== null,
|
||||
available: details !== null,
|
||||
asOf,
|
||||
totals: linkSummary?.totals ?? null
|
||||
timeframes,
|
||||
countsByTimeframe,
|
||||
chart
|
||||
};
|
||||
};
|
||||
}) satisfies PageServerLoad;
|
||||
+36
-10
@@ -1,10 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolveVisitTimeframe, visitGranularity, visitWindow } from '@goto/analytics/daily';
|
||||
import VisitsChart from '#lib/components/admin/VisitsChart.svelte';
|
||||
import VisitTimeframes from '#lib/components/admin/VisitTimeframes.svelte';
|
||||
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 { 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);
|
||||
</script>
|
||||
|
||||
<AdminPage
|
||||
@@ -12,26 +25,39 @@
|
||||
title="Link"
|
||||
documentTitle={`Link — ${data.domain}${data.path}`}
|
||||
updatedAt={data.asOf}
|
||||
overviewHref={`/admin?period=${timeframe}`}
|
||||
>
|
||||
{#snippet context()}
|
||||
<nav aria-label="Breadcrumb">
|
||||
<a href={data.domainHref}>{data.domain}</a> <span>{data.path}</span>
|
||||
</nav>
|
||||
{#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">
|
||||
<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} />
|
||||
{/if}
|
||||
<StatCard label="Status" value={data.state} />
|
||||
</div>
|
||||
<section class="admin-info" aria-label="Link details">
|
||||
<section class="admin-info" aria-labelledby="link-details-heading">
|
||||
<h2 id="link-details-heading">Link details</h2>
|
||||
<dl>
|
||||
<dt>Domain</dt>
|
||||
<dd><a href={`${data.domainHref}?period=${timeframe}`}>{data.domain}</a></dd>
|
||||
<dt>Path</dt>
|
||||
<dd>{data.path}</dd>
|
||||
<dt>Destination</dt>
|
||||
<dd>
|
||||
{#if data.url}<a class="admin-destination" href={data.url}>{data.url}</a>
|
||||
{:else if data.fallback === 404}404 Not found{:else}{data.fallback}{/if}
|
||||
</dd>
|
||||
<dt>State</dt>
|
||||
<dd>{data.state}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
{#if data.chart && window}
|
||||
<VisitsChart daily={data.chart.daily} {window} {granularity} />
|
||||
{/if}
|
||||
</AdminPage>
|
||||
+75
-45
@@ -1,11 +1,11 @@
|
||||
import { validateConfig } from '@goto/config';
|
||||
import type { LinkSummary } from '@goto/analytics';
|
||||
import { createVisitPeriod, visitDays, 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 { readVisitDetails } 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', () => ({ readVisitDetails: vi.fn() }));
|
||||
beforeEach(() => vi.resetAllMocks());
|
||||
|
||||
const domain = 'go.example.test';
|
||||
@@ -31,59 +31,79 @@ function request(path = '/docs', host = domain, retention_months = 3) {
|
||||
params: { domain: host, path: path.slice(1) }
|
||||
} as Parameters<typeof load>[0];
|
||||
}
|
||||
const summary: LinkSummary = {
|
||||
|
||||
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z'));
|
||||
const counts: Record<string, [number, number]> = {
|
||||
'2026-03-15': [4, 1],
|
||||
'2026-05-16': [3, 2],
|
||||
'2026-06-08': [5, 1],
|
||||
'2026-06-09': [2, 1],
|
||||
'2026-06-15': [1, 0]
|
||||
};
|
||||
const details: VisitDetails = {
|
||||
version: 1,
|
||||
scope: { kind: 'link', domain, path: '/docs' },
|
||||
period: {
|
||||
from: '2026-03-15T12:00:00.000Z',
|
||||
asOf: '2026-06-15T12:00:00.000Z',
|
||||
timezone: 'UTC',
|
||||
retentionMonths: 3
|
||||
},
|
||||
totals: { validVisits: 2, invalidVisits: 1 }
|
||||
period,
|
||||
daily: visitDays(period).map((day) => {
|
||||
const [validVisits, invalidVisits] = counts[day.start.slice(0, 10)] ?? [0, 0];
|
||||
return { ...day, validVisits, invalidVisits };
|
||||
}),
|
||||
browsers: [{ family: 'Other', validVisits: 15, invalidVisits: 5 }],
|
||||
operatingSystems: [{ family: 'Other', validVisits: 15, invalidVisits: 5 }]
|
||||
};
|
||||
|
||||
test('loads the requested path summary and current destination with a domain breadcrumb', async () => {
|
||||
vi.mocked(readVisitSummary).mockResolvedValue(summary);
|
||||
test('projects only the requested link series and compact window counts with current configuration', async () => {
|
||||
vi.mocked(readVisitDetails).mockResolvedValue(details);
|
||||
const data = await load(request());
|
||||
if (!data) throw new Error('Missing link page');
|
||||
expect(readVisitSummary).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
|
||||
expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
|
||||
kind: 'link',
|
||||
domain,
|
||||
path: '/docs'
|
||||
});
|
||||
expect(data.path).toBe('/docs');
|
||||
expect(data.totals).toEqual(summary.totals);
|
||||
expect(data.url).toBe('https://example.test/docs');
|
||||
expect(data.state).toBe('Active');
|
||||
expect(data.domainHref).toBe('/admin/domains/go.example.test');
|
||||
expect(data.asOf).toBe(Date.parse(summary.period.asOf));
|
||||
expect(data.retentionMonths).toBe(3);
|
||||
expect(data).toEqual({
|
||||
domain,
|
||||
path: '/docs',
|
||||
domainHref: '/admin/domains/go.example.test',
|
||||
url: 'https://example.test/docs',
|
||||
fallback: 404,
|
||||
state: 'Active',
|
||||
retentionMonths: 3,
|
||||
collectionEnabled: true,
|
||||
available: true,
|
||||
asOf: Date.parse(period.asOf),
|
||||
timeframes: ['7d', '30d', 'all'],
|
||||
countsByTimeframe: {
|
||||
'7d': { validVisits: 3, invalidVisits: 1 },
|
||||
'30d': { validVisits: 8, invalidVisits: 2 },
|
||||
all: { validVisits: 15, invalidVisits: 5 }
|
||||
},
|
||||
chart: { daily: details.daily, period }
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps decoded nested and literal-percent paths distinct and supports the unconfigured root', async () => {
|
||||
vi.mocked(readVisitSummary)
|
||||
.mockResolvedValueOnce({ ...summary, scope: { kind: 'link', domain, path: '/a/b' } })
|
||||
.mockResolvedValueOnce({ ...summary, scope: { kind: 'link', domain, path: '/a%2Fb' } })
|
||||
.mockResolvedValueOnce({ ...summary, scope: { kind: 'link', domain, path: '/' } });
|
||||
test('keeps decoded nested and literal-percent identities distinct and supports historical root traffic', async () => {
|
||||
vi.mocked(readVisitDetails)
|
||||
.mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/a/b' } })
|
||||
.mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/a%2Fb' } })
|
||||
.mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/' } });
|
||||
const nested = await load(request('/a/b'));
|
||||
const percent = await load(request('/a%2Fb'));
|
||||
const root = await load(request('/'));
|
||||
if (!nested || !percent || !root) throw new Error('Missing path page');
|
||||
expect(nested.path).toBe('/a/b');
|
||||
expect(percent.path).toBe('/a%2Fb');
|
||||
expect(percent.state).toBe('Disabled');
|
||||
expect(readVisitSummary).toHaveBeenNthCalledWith(1, expect.any(Object), {
|
||||
expect(percent.countsByTimeframe.all?.validVisits).toBe(15);
|
||||
expect(readVisitDetails).toHaveBeenNthCalledWith(1, expect.any(Object), {
|
||||
kind: 'link',
|
||||
domain,
|
||||
path: '/a/b'
|
||||
});
|
||||
expect(readVisitSummary).toHaveBeenNthCalledWith(2, expect.any(Object), {
|
||||
expect(readVisitDetails).toHaveBeenNthCalledWith(2, expect.any(Object), {
|
||||
kind: 'link',
|
||||
domain,
|
||||
path: '/a%2Fb'
|
||||
});
|
||||
expect(readVisitSummary).toHaveBeenNthCalledWith(3, expect.any(Object), {
|
||||
expect(readVisitDetails).toHaveBeenNthCalledWith(3, expect.any(Object), {
|
||||
kind: 'link',
|
||||
domain,
|
||||
path: '/'
|
||||
@@ -92,22 +112,32 @@ test('keeps decoded nested and literal-percent paths distinct and supports the u
|
||||
expect(root.state).toBe('Fallback');
|
||||
expect(root.url).toBeNull();
|
||||
expect(root.fallback).toBe(404);
|
||||
expect(root.countsByTimeframe.all?.validVisits).toBe(15);
|
||||
});
|
||||
|
||||
test('rejects unknown scopes before reading and keeps unavailable or disabled counts empty', async () => {
|
||||
test('rejects unknown scopes before reading and keeps unavailable counts absent', async () => {
|
||||
await expect(load(request('/docs', 'unknown.test'))).rejects.toMatchObject({ status: 404 });
|
||||
await expect(load(request('/not-configured'))).rejects.toMatchObject({ status: 404 });
|
||||
expect(readVisitSummary).not.toHaveBeenCalled();
|
||||
vi.mocked(readVisitSummary).mockResolvedValue(null);
|
||||
const missing = await load(request());
|
||||
if (!missing) throw new Error('Missing unavailable page');
|
||||
expect(missing.available).toBe(false);
|
||||
expect(missing.totals).toBeNull();
|
||||
vi.clearAllMocks();
|
||||
const disabled = await load(request('/docs', domain, 0));
|
||||
if (!disabled) throw new Error('Missing disabled page');
|
||||
expect(disabled.collectionEnabled).toBe(false);
|
||||
expect(disabled.retentionMonths).toBe(0);
|
||||
expect(disabled.totals).toBeNull();
|
||||
expect(readVisitSummary).not.toHaveBeenCalled();
|
||||
expect(readVisitDetails).not.toHaveBeenCalled();
|
||||
vi.mocked(readVisitDetails).mockResolvedValue(null);
|
||||
const data = await load(request());
|
||||
expect(data.available).toBe(false);
|
||||
expect(data.chart).toBeNull();
|
||||
expect(data.asOf).toBeNull();
|
||||
expect(data.countsByTimeframe).toEqual({});
|
||||
expect(data.url).toBe('https://example.test/docs');
|
||||
expect(data.retentionMonths).toBe(3);
|
||||
});
|
||||
|
||||
test('disabled analytics skips the report read and leaves link details usable', async () => {
|
||||
const data = await load(request('/docs', domain, 0));
|
||||
expect(readVisitDetails).not.toHaveBeenCalled();
|
||||
expect(data.collectionEnabled).toBe(false);
|
||||
expect(data.available).toBe(false);
|
||||
expect(data.retentionMonths).toBe(0);
|
||||
expect(data.timeframes).toEqual([]);
|
||||
expect(data.chart).toBeNull();
|
||||
expect(data.countsByTimeframe).toEqual({});
|
||||
expect(data.url).toBe('https://example.test/docs');
|
||||
expect(data.state).toBe('Active');
|
||||
});
|
||||
Reference in new issue
Block a user