feat(admin): add browser and OS breakdowns #22

Merged
eleith merged 2 commits from feat/browser-os-breakdowns into main 2026-11-12 02:47:35 +00:00
30 changed files with 688 additions and 120 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@goto/web': minor
'@goto/worker': minor
'@goto/cli': minor
---
Add browser and operating-system breakdowns to domain and link pages, including `/`, using the selected visits timeframe. Prepare sparse browser and operating-system counts by UTC day in versioned detail reports and show readable counts, percentages and bars without changing visit capture, classification or retention.
+19
View File
@@ -129,6 +129,15 @@ test('shared credentials protect overview, domain and link pages and sign-out re
const context = page.locator('.admin-context');
await expect(context).toHaveCount(0);
await expect(page).toHaveTitle('Domain — go.example.org — Goto');
const browsers = page.getByRole('region', { name: 'Browsers', exact: true });
const operatingSystems = page.getByRole('region', { name: 'Operating systems', exact: true });
await expect(browsers.getByRole('columnheader')).toHaveText(['Browser', 'Visits', 'Share']);
await expect(operatingSystems.getByRole('columnheader')).toHaveText([
'Operating system',
'Visits',
'Share'
]);
await expect(browsers.getByRole('rowheader', { name: 'Other', exact: true })).toBeVisible();
const overviewLink = page
.getByRole('navigation', { name: 'Administration', exact: true })
.getByRole('link');
@@ -233,10 +242,16 @@ test('shared credentials protect overview, domain and link pages and sign-out re
await expect(graph).toHaveAttribute('aria-busy', 'false');
await expect(chartTable).toHaveAccessibleName('Visits');
await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 1\./);
await expect(browsers.getByRole('row', { name: 'Firefox 1 100%', exact: true })).toBeVisible();
await expect(
operatingSystems.getByRole('row', { name: 'Linux 1 100%', exact: true })
).toBeVisible();
await expect(visitsValue).toHaveText('1');
await timeframe.getByRole('button', { name: '7 days', exact: true }).click();
await expect(visitsValue).toHaveText('0');
await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 0\./);
await expect(browsers).toContainText('No visits in this period.');
await expect(operatingSystems).toContainText('No visits in this period.');
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');
@@ -326,6 +341,10 @@ test.describe('analytics disabled', () => {
).toHaveText(['Domain', 'Base destination', 'Base status']);
await page.getByRole('link', { name: '/docs', exact: true }).click();
await expect(page.getByRole('region', { name: 'Link details', exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: 'Browsers', exact: true })).toHaveCount(0);
await expect(page.getByRole('region', { name: 'Operating systems', exact: true })).toHaveCount(
0
);
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);
+3 -1
View File
@@ -65,7 +65,9 @@ async function seedVisits() {
domain: 'go.example.org',
path: '/docs',
outcome: 'valid',
visited_at: Date.now() - 14 * 86_400_000
visited_at: Date.now() - 14 * 86_400_000,
browser: 'Firefox',
os: 'Linux'
},
{ domain: 'go.example.org', path: '/a/b', outcome: 'invalid' },
{ domain: 'go.example.org', path: '/', outcome: 'invalid' },
@@ -0,0 +1,135 @@
<script lang="ts">
import type { VisitCountByName, BrowserOsVisits } from '@goto/analytics';
import { formatCount } from './format.js';
let { browserOsVisits, totalVisits }: { browserOsVisits: BrowserOsVisits; totalVisits: number } =
$props();
const percentFormat = new Intl.NumberFormat('en', {
style: 'percent',
maximumFractionDigits: 1
});
</script>
{#snippet visitCountsCard(title: string, nameColumnTitle: string, visitCounts: VisitCountByName[])}
<section class="breakdown-card" aria-label={title}>
<h2>{title}</h2>
{#if totalVisits === 0}
<p>No visits in this period.</p>
{:else}
<table aria-label={title}>
<thead>
<tr>
<th scope="col">{nameColumnTitle}</th>
<th scope="col">Visits</th>
<th scope="col">Share</th>
</tr>
</thead>
<tbody>
{#each visitCounts as visitCount (visitCount.name)}
{@const share = visitCount.visits / totalVisits}
<tr>
<th scope="row">
{visitCount.name}
<span class="bar-track" aria-hidden="true">
<span class="bar-fill" style:width={`${share * 100}%`}></span>
</span>
</th>
<td>{formatCount(visitCount.visits)}</td>
<td>{percentFormat.format(share)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{/snippet}
<div class="browser-os-breakdowns">
{@render visitCountsCard('Browsers', 'Browser', browserOsVisits.browsers)}
{@render visitCountsCard(
'Operating systems',
'Operating system',
browserOsVisits.operatingSystems
)}
</div>
<style>
.browser-os-breakdowns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-5);
}
.breakdown-card {
min-width: 0;
padding: var(--space-5);
background: var(--color-surface-card);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-card);
}
h2 {
margin: 0 0 var(--space-5);
}
table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: var(--font-size-sm);
}
th,
td {
padding: var(--space-4) var(--space-1);
text-align: start;
vertical-align: top;
overflow-wrap: anywhere;
}
thead {
background: var(--color-surface-muted);
}
thead th:not(:first-child) {
width: 24%;
text-align: end;
}
tbody th,
tbody td {
border-top: var(--border-width) solid var(--color-border-subtle);
font-weight: 400;
}
td {
text-align: end;
font-variant-numeric: tabular-nums;
}
.bar-track {
display: block;
height: 0.375rem;
margin-top: var(--space-1);
overflow: hidden;
background: var(--color-surface-muted);
border-radius: var(--radius-card);
}
.bar-fill {
display: block;
height: 100%;
background: var(--color-primary);
}
p {
color: var(--color-text-muted);
}
@media (max-width: 640px) {
.browser-os-breakdowns {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -36,7 +36,7 @@ test('reads prepared output and hides snapshots from a different retention polic
};
const days = visitDays(period);
const details: VisitDetails = {
version: 1,
version: 2,
scope: summary.scope,
period,
daily: days.map((day, index) => ({
@@ -44,8 +44,8 @@ test('reads prepared output and hides snapshots from a different retention polic
validVisits: index === days.length - 1 ? totals.validVisits : 0,
invalidVisits: index === days.length - 1 ? totals.invalidVisits : 0
})),
browsers: [{ family: 'Other', ...totals }],
operatingSystems: [{ family: 'Other', ...totals }]
browsers: [{ day: period.asOf.slice(0, 10), name: 'Other', ...totals }],
operatingSystems: [{ day: period.asOf.slice(0, 10), name: 'Other', ...totals }]
};
const domain = 'go.example.test';
const domainScope = { kind: 'domain', domain } as const;
@@ -1,4 +1,5 @@
import { error } from '@sveltejs/kit';
import { countBrowserOsVisitsByTimeframe } from '@goto/analytics';
import {
availableVisitTimeframes,
countVisitWindows,
@@ -31,6 +32,10 @@ export const load = (async ({ locals, params }) => {
: [];
const detailsByPath = new Map(report?.links.map(({ path, details }) => [path, details]));
const countsByTimeframe = countVisitWindows(domainDetails?.daily, windows);
const browserOsVisitsByTimeframe = countBrowserOsVisitsByTimeframe(
domainDetails ?? undefined,
windows
);
const baseCountsByTimeframe = countVisitWindows(detailsByPath.get('/')?.daily, windows);
const invalidClicksByTimeframe: Partial<Record<VisitTimeframe, number>> = {};
for (const { timeframe } of windows) {
@@ -71,6 +76,7 @@ export const load = (async ({ locals, params }) => {
linkCount: links.length,
timeframes,
countsByTimeframe,
browserOsVisitsByTimeframe,
invalidClicksByTimeframe,
chart,
base: {
@@ -5,6 +5,7 @@
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 BrowserOsBreakdowns from '#lib/components/admin/BrowserOsBreakdowns.svelte';
import VisitTimeframes from '#lib/components/admin/VisitTimeframes.svelte';
import LinkInventory from '#lib/components/admin/LinkInventory.svelte';
import type { PageData } from './$types';
@@ -21,6 +22,7 @@
let visits = $derived(data.countsByTimeframe[timeframe]?.validVisits ?? null);
let baseVisits = $derived(data.base.countsByTimeframe[timeframe]?.validVisits ?? null);
let invalidClicks = $derived(data.invalidClicksByTimeframe[timeframe] ?? null);
let browserOsVisits = $derived(data.browserOsVisitsByTimeframe[timeframe] ?? null);
</script>
<AdminPage
@@ -71,6 +73,9 @@
{#if data.chart && window}
<VisitsChart daily={data.chart.daily} {window} {granularity} />
{/if}
{#if browserOsVisits && visits !== null}
<BrowserOsBreakdowns {browserOsVisits} totalVisits={visits} />
{/if}
<section>
<h2>Links</h2>
{#if data.links.length === 0}<p>No links configured.</p>
@@ -1,4 +1,5 @@
import { error } from '@sveltejs/kit';
import { countBrowserOsVisitsByTimeframe } from '@goto/analytics';
import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily';
import { currentLinkState } from '#lib/server/link-state.js';
import { readVisitDetails } from '#lib/server/visit-reports.js';
@@ -23,6 +24,7 @@ export const load = (async ({ locals, params }) => {
? timeframes.map((timeframe) => ({ timeframe, window: visitWindow(details.period, timeframe) }))
: [];
const countsByTimeframe = countVisitWindows(details?.daily, windows);
const browserOsVisitsByTimeframe = countBrowserOsVisitsByTimeframe(details ?? undefined, windows);
const state = link ? currentLinkState(link, Date.now()) : 'Fallback';
const asOf = details ? Date.parse(details.period.asOf) : null;
const chart = details ? { daily: details.daily, period: details.period } : null;
@@ -40,6 +42,7 @@ export const load = (async ({ locals, params }) => {
asOf,
timeframes,
countsByTimeframe,
browserOsVisitsByTimeframe,
chart
};
}) satisfies PageServerLoad;
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { resolveVisitTimeframe, visitGranularity, visitWindow } from '@goto/analytics/daily';
import VisitsChart from '#lib/components/admin/VisitsChart.svelte';
import BrowserOsBreakdowns from '#lib/components/admin/BrowserOsBreakdowns.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';
@@ -18,6 +19,7 @@
data.chart ? visitGranularity(timeframe, data.chart.period.retentionMonths) : 'day'
);
let visits = $derived(data.countsByTimeframe[timeframe]?.validVisits ?? null);
let browserOsVisits = $derived(data.browserOsVisitsByTimeframe[timeframe] ?? null);
</script>
<AdminPage
@@ -60,4 +62,7 @@
{#if data.chart && window}
<VisitsChart daily={data.chart.daily} {window} {granularity} />
{/if}
{#if browserOsVisits && visits !== null}
<BrowserOsBreakdowns {browserOsVisits} totalVisits={visits} />
{/if}
</AdminPage>
@@ -40,19 +40,25 @@ const counts: Record<string, [number, number]> = {
'2026-06-09': [2, 1],
'2026-06-15': [1, 0]
};
const browserOsCounts = Object.entries(counts).map(([day, [validVisits, invalidVisits]]) => ({
day,
name: 'Other',
validVisits,
invalidVisits
}));
const details: VisitDetails = {
version: 1,
version: 2,
scope: { kind: 'link', domain, path: '/docs' },
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 }]
browsers: browserOsCounts,
operatingSystems: browserOsCounts
};
test('projects only the requested link series and compact window counts with current configuration', async () => {
test('projects only the requested link series and compact window counts and breakdowns', async () => {
vi.mocked(readVisitDetails).mockResolvedValue(details);
const data = await load(request());
expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), {
@@ -77,6 +83,20 @@ test('projects only the requested link series and compact window counts with cur
'30d': { validVisits: 8, invalidVisits: 2 },
all: { validVisits: 15, invalidVisits: 5 }
},
browserOsVisitsByTimeframe: {
'7d': {
browsers: [{ name: 'Other', visits: 3 }],
operatingSystems: [{ name: 'Other', visits: 3 }]
},
'30d': {
browsers: [{ name: 'Other', visits: 8 }],
operatingSystems: [{ name: 'Other', visits: 8 }]
},
all: {
browsers: [{ name: 'Other', visits: 15 }],
operatingSystems: [{ name: 'Other', visits: 15 }]
}
},
chart: { daily: details.daily, period }
});
});
@@ -122,6 +142,7 @@ test('rejects unknown scopes before reading and keeps unavailable counts absent'
vi.mocked(readVisitDetails).mockResolvedValue(null);
const data = await load(request());
expect(data.available).toBe(false);
expect(data.browserOsVisitsByTimeframe).toEqual({});
expect(data.chart).toBeNull();
expect(data.asOf).toBeNull();
expect(data.countsByTimeframe).toEqual({});
@@ -133,6 +154,7 @@ test('disabled analytics skips the report read and leaves link details usable',
const data = await load(request('/docs', domain, 0));
expect(readVisitDetails).not.toHaveBeenCalled();
expect(data.collectionEnabled).toBe(false);
expect(data.browserOsVisitsByTimeframe).toEqual({});
expect(data.available).toBe(false);
expect(data.retentionMonths).toBe(0);
expect(data.timeframes).toEqual([]);
@@ -37,17 +37,21 @@ function details(scope: ReportScope, counts: Record<string, [number, number]>):
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)
};
const browserOsCounts = daily
.filter(({ validVisits, invalidVisits }) => validVisits > 0 || invalidVisits > 0)
.map(({ start, validVisits, invalidVisits }) => ({
day: start.slice(0, 10),
name: 'Other',
validVisits,
invalidVisits
}));
return {
version: 1,
version: 2,
scope,
period,
daily,
browsers: [{ family: 'Other', ...totals }],
operatingSystems: [{ family: 'Other', ...totals }]
browsers: browserOsCounts,
operatingSystems: browserOsCounts
};
}
@@ -93,7 +97,7 @@ const baseCounts = {
all: { validVisits: 3, invalidVisits: 4 }
};
test('projects consistent domain, base and link windows without exposing link series or families', async () => {
test('projects domain counts and browser/OS visits without exposing link series or dated browser/OS counts', async () => {
vi.mocked(readDomainDetails).mockResolvedValue(report);
const data = await load(request());
expect(readDomainDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), domain, [
@@ -114,6 +118,20 @@ test('projects consistent domain, base and link windows without exposing link se
'30d': { validVisits: 7, invalidVisits: 8 },
all: { validVisits: 15, invalidVisits: 14 }
},
browserOsVisitsByTimeframe: {
'7d': {
browsers: [{ name: 'Other', visits: 5 }],
operatingSystems: [{ name: 'Other', visits: 5 }]
},
'30d': {
browsers: [{ name: 'Other', visits: 7 }],
operatingSystems: [{ name: 'Other', visits: 7 }]
},
all: {
browsers: [{ name: 'Other', visits: 15 }],
operatingSystems: [{ name: 'Other', visits: 15 }]
}
},
invalidClicksByTimeframe: { '7d': 3, '30d': 5, all: 10 },
chart: { daily: domainDetails.daily, period },
base: {
@@ -185,6 +203,7 @@ test('rejects unknown domains before reading and keeps unavailable inventory usa
vi.mocked(readDomainDetails).mockResolvedValue(null);
const data = await load(request());
expect(data.available).toBe(false);
expect(data.browserOsVisitsByTimeframe).toEqual({});
expect(data.chart).toBeNull();
expect(data.countsByTimeframe).toEqual({});
expect(data.invalidClicksByTimeframe).toEqual({});
@@ -196,6 +215,7 @@ test('disabled collection reads no reports and preserves current inventory', asy
const data = await load(request(domain, undefined, 0));
expect(readDomainDetails).not.toHaveBeenCalled();
expect(data.collectionEnabled).toBe(false);
expect(data.browserOsVisitsByTimeframe).toEqual({});
expect(data.timeframes).toEqual([]);
expect(data.chart).toBeNull();
expect(data.countsByTimeframe).toEqual({});
@@ -49,17 +49,21 @@ function details(
validVisits: validVisits(day.start.slice(0, 10)),
invalidVisits: 1
}));
const totals = {
validVisits: daily.reduce((sum, day) => sum + day.validVisits, 0),
invalidVisits: daily.length
};
const browserOsCounts = daily
.filter(({ validVisits, invalidVisits }) => validVisits > 0 || invalidVisits > 0)
.map(({ start, validVisits, invalidVisits }) => ({
day: start.slice(0, 10),
name: 'Other',
validVisits,
invalidVisits
}));
return {
version: 1,
version: 2,
scope,
period,
daily,
browsers: [{ family: 'Other', ...totals }],
operatingSystems: [{ family: 'Other', ...totals }]
browsers: browserOsCounts,
operatingSystems: browserOsCounts
};
}
@@ -33,9 +33,19 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
scopeKind: 'overview',
domain: null,
path: null,
family: 'Firefox',
validVisits: 3,
invalidVisits: 5
day: '2026-02-28',
name: 'Firefox',
validVisits: 2,
invalidVisits: 1
},
{
scopeKind: 'overview',
domain: null,
path: null,
day: '2026-05-31',
name: 'Firefox',
validVisits: 1,
invalidVisits: 4
}
],
operatingSystems: [
@@ -43,9 +53,19 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
scopeKind: 'overview',
domain: null,
path: null,
family: 'Linux',
validVisits: 3,
invalidVisits: 5
day: '2026-02-28',
name: 'Linux',
validVisits: 2,
invalidVisits: 1
},
{
scopeKind: 'overview',
domain: null,
path: null,
day: '2026-05-31',
name: 'Linux',
validVisits: 1,
invalidVisits: 4
}
]
},
@@ -61,6 +81,15 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
base: { validVisits: 1, invalidVisits: 2 },
domains: []
});
expect(report?.details.version).toBe(2);
expect(report?.details.browsers).toEqual([
{ day: '2026-02-28', name: 'Firefox', validVisits: 2, invalidVisits: 1 },
{ day: '2026-05-31', name: 'Firefox', validVisits: 1, invalidVisits: 4 }
]);
expect(report?.details.operatingSystems).toEqual([
{ day: '2026-02-28', name: 'Linux', validVisits: 2, invalidVisits: 1 },
{ day: '2026-05-31', name: 'Linux', validVisits: 1, invalidVisits: 4 }
]);
expect(report?.details.daily).toHaveLength(visitDays(period).length);
expect(report?.details.daily[0]).toEqual({
start: '2026-02-28T00:00:00.000Z',
@@ -85,7 +114,7 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges',
});
});
test('indexes explicit domain and link scopes, including empty links and root, without mixing families', () => {
test('indexes explicit domain and link scopes, including empty links and root, without mixing browser/OS counts', () => {
const period = createVisitPeriod(3, Date.parse('2026-07-01T12:00:00Z'));
const overview = { scopeKind: 'overview' as const, domain: null, path: null };
const domain = { scopeKind: 'domain' as const, domain: 'go.example.test', path: null };
@@ -128,16 +157,16 @@ test('indexes explicit domain and link scopes, including empty links and root, w
}
],
browsers: [
{ ...overview, family: 'Other', validVisits: 8, invalidVisits: 3 },
{ ...domain, family: 'Firefox', validVisits: 4, invalidVisits: 2 },
{ ...link, family: 'Chrome', validVisits: 2, invalidVisits: 0 },
{ ...root, family: 'Firefox', validVisits: 1, invalidVisits: 1 }
{ ...overview, day: '2026-06-01', name: 'Other', validVisits: 8, invalidVisits: 3 },
{ ...domain, day: '2026-06-01', name: 'Firefox', validVisits: 4, invalidVisits: 2 },
{ ...link, day: '2026-06-01', name: 'Chrome', validVisits: 2, invalidVisits: 0 },
{ ...root, day: '2026-06-01', name: 'Firefox', validVisits: 1, invalidVisits: 1 }
],
operatingSystems: [
{ ...overview, family: 'Other', validVisits: 8, invalidVisits: 3 },
{ ...domain, family: 'Linux', validVisits: 4, invalidVisits: 2 },
{ ...link, family: 'Linux', validVisits: 2, invalidVisits: 0 },
{ ...root, family: 'Linux', validVisits: 1, invalidVisits: 1 }
{ ...overview, day: '2026-06-01', name: 'Other', validVisits: 8, invalidVisits: 3 },
{ ...domain, day: '2026-06-01', name: 'Linux', validVisits: 4, invalidVisits: 2 },
{ ...link, day: '2026-06-01', name: 'Linux', validVisits: 2, invalidVisits: 0 },
{ ...root, day: '2026-06-01', name: 'Linux', validVisits: 1, invalidVisits: 1 }
]
},
[
@@ -188,12 +217,14 @@ test('indexes explicit domain and link scopes, including empty links and root, w
period,
totals: { validVisits: 2, invalidVisits: 0 }
});
expect(docs?.details.browsers).toEqual([{ family: 'Chrome', validVisits: 2, invalidVisits: 0 }]);
expect(docs?.details.browsers).toEqual([
{ day: '2026-06-01', name: 'Chrome', validVisits: 2, invalidVisits: 0 }
]);
expect(domainReport?.details.browsers).toEqual([
{ family: 'Firefox', validVisits: 4, invalidVisits: 2 }
{ day: '2026-06-01', name: 'Firefox', validVisits: 4, invalidVisits: 2 }
]);
expect(base?.details.operatingSystems).toEqual([
{ family: 'Linux', validVisits: 1, invalidVisits: 1 }
{ day: '2026-06-01', name: 'Linux', validVisits: 1, invalidVisits: 1 }
]);
expect(empty?.summary.totals).toEqual({ validVisits: 0, invalidVisits: 0 });
expect(empty?.details.daily).toEqual(
+11 -10
View File
@@ -14,13 +14,13 @@ import type {
AggregatedVisitReports,
DailyVisitCounts,
ReportInventory,
ScopedFamilyCounts
DailyBrowserOsCounts
} from '@goto/db/visit-reports';
interface ScopeCounts {
daily: DailyVisitCounts[];
browsers: ScopedFamilyCounts[];
operatingSystems: ScopedFamilyCounts[];
browsers: DailyBrowserOsCounts[];
operatingSystems: DailyBrowserOsCounts[];
}
interface DomainCounts {
@@ -81,7 +81,7 @@ function indexCounts(aggregates: AggregatedVisitReports): ReportCounts {
function countsForScope(
indexed: ReportCounts,
scope: DailyVisitCounts | ScopedFamilyCounts
scope: DailyVisitCounts | DailyBrowserOsCounts
): ScopeCounts {
if (scope.scopeKind === 'overview') return indexed.overview;
let domain = indexed.domains.get(scope.domain);
@@ -153,12 +153,12 @@ function buildDetails(
days: ReturnType<typeof visitDays>
): VisitDetails {
return {
version: 1,
version: 2,
scope,
period,
daily: buildDailyDetails(counts.daily, days),
browsers: buildFamilyDetails(counts.browsers),
operatingSystems: buildFamilyDetails(counts.operatingSystems)
browsers: buildDailyBrowserOsDetails(counts.browsers),
operatingSystems: buildDailyBrowserOsDetails(counts.operatingSystems)
};
}
@@ -177,9 +177,10 @@ function buildDailyDetails(
});
}
function buildFamilyDetails(rows: ScopedFamilyCounts[]): VisitDetails['browsers'] {
return rows.map(({ family, validVisits, invalidVisits }) => ({
family,
function buildDailyBrowserOsDetails(rows: DailyBrowserOsCounts[]): VisitDetails['browsers'] {
return rows.map(({ day, name, validVisits, invalidVisits }) => ({
day,
name,
validVisits,
invalidVisits
}));
@@ -83,9 +83,14 @@ test('publishes summary/detail pairs for all scopes, deletes expired history and
invalidVisits: 0
});
expect(sum(details!.daily)).toEqual(summary?.totals);
expect(details?.browsers).toEqual([{ family: 'Firefox', validVisits: 1, invalidVisits: 1 }]);
expect(details?.version).toBe(2);
expect(details?.browsers).toEqual([
{ day: '2026-02-28', name: 'Firefox', validVisits: 1, invalidVisits: 0 },
{ day: '2026-05-31', name: 'Firefox', validVisits: 0, invalidVisits: 1 }
]);
expect(details?.operatingSystems).toEqual([
{ family: 'Linux', validVisits: 1, invalidVisits: 1 }
{ day: '2026-02-28', name: 'Linux', validVisits: 1, invalidVisits: 0 },
{ day: '2026-05-31', name: 'Linux', validVisits: 0, invalidVisits: 1 }
]);
expect(await ctx.db.selectFrom('analytics_reports').select('id').execute()).toHaveLength(4);
expect(await ctx.db.selectFrom('visits').select('id').execute()).toHaveLength(2);
@@ -0,0 +1,100 @@
import { expect, test } from 'vitest';
import { visitWindow, type VisitTimeframe } from './daily.js';
import { countBrowserOsVisitsByTimeframe } from './index.js';
import { createVisitPeriod } from './period.js';
import type { VisitDetails } from './reports.js';
test('counts only valid visits in each UTC timeframe, combining browser/OS counts and sorting ties by name', () => {
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z'));
const details: Pick<VisitDetails, 'browsers' | 'operatingSystems'> = {
browsers: [
{ day: '2026-03-15', name: 'Other', validVisits: 100, invalidVisits: 0 },
{ day: '2026-06-09', name: 'Firefox', validVisits: 2, invalidVisits: 40 },
{ day: '2026-06-09', name: 'Chrome', validVisits: 3, invalidVisits: 0 },
{ day: '2026-06-15', name: 'Firefox', validVisits: 2, invalidVisits: 0 },
{ day: '2026-06-15', name: 'Chrome', validVisits: 1, invalidVisits: 0 },
{ day: '2026-06-15', name: 'Other', validVisits: 5, invalidVisits: 0 },
{ day: '2026-06-15', name: 'Safari', validVisits: 0, invalidVisits: 90 }
],
operatingSystems: [
{ day: '2026-03-15', name: 'Windows', validVisits: 100, invalidVisits: 0 },
{ day: '2026-06-09', name: 'Linux', validVisits: 5, invalidVisits: 40 },
{ day: '2026-06-15', name: 'Linux', validVisits: 8, invalidVisits: 0 },
{ day: '2026-06-15', name: 'Other', validVisits: 0, invalidVisits: 90 }
]
};
const original = structuredClone(details);
const timeframes: VisitTimeframe[] = ['7d', 'all'];
const windows = timeframes.map((timeframe) => ({
timeframe,
window: visitWindow(period, timeframe)
}));
expect(countBrowserOsVisitsByTimeframe(details, windows)).toEqual({
'7d': {
browsers: [
{ name: 'Other', visits: 5 },
{ name: 'Chrome', visits: 4 },
{ name: 'Firefox', visits: 4 }
],
operatingSystems: [{ name: 'Linux', visits: 13 }]
},
all: {
browsers: [
{ name: 'Other', visits: 105 },
{ name: 'Chrome', visits: 4 },
{ name: 'Firefox', visits: 4 }
],
operatingSystems: [
{ name: 'Windows', visits: 100 },
{ name: 'Linux', visits: 13 }
]
}
});
expect(details).toEqual(original);
});
test('intersects UTC days at partial and exclusive boundaries rather than filtering by midnight alone', () => {
const rows = [
{ day: '2024-02-27', name: 'Other', validVisits: 100, invalidVisits: 0 },
{ day: '2024-02-28', name: 'Other', validVisits: 2, invalidVisits: 0 },
{ day: '2024-02-29', name: 'Other', validVisits: 3, invalidVisits: 0 },
{ day: '2024-03-01', name: 'Other', validVisits: 100, invalidVisits: 0 }
];
expect(
countBrowserOsVisitsByTimeframe({ browsers: rows, operatingSystems: rows }, [
{
timeframe: 'all',
window: { from: '2024-02-28T12:00:00.000Z', asOf: '2024-03-01T00:00:00.000Z' }
}
])
).toEqual({
all: {
browsers: [{ name: 'Other', visits: 5 }],
operatingSystems: [{ name: 'Other', visits: 5 }]
}
});
});
test('missing reports remain unavailable while empty and invalid-only reports have explicit empty windows', () => {
const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z'));
const timeframes: VisitTimeframe[] = ['7d', '30d', 'all'];
const windows = timeframes.map((timeframe) => ({
timeframe,
window: visitWindow(period, timeframe)
}));
const empty = {
'7d': { browsers: [], operatingSystems: [] },
'30d': { browsers: [], operatingSystems: [] },
all: { browsers: [], operatingSystems: [] }
};
expect(countBrowserOsVisitsByTimeframe(undefined, windows)).toEqual({});
expect(countBrowserOsVisitsByTimeframe({ browsers: [], operatingSystems: [] }, windows)).toEqual(
empty
);
const rows = [{ day: '2026-06-15', name: 'Other', validVisits: 0, invalidVisits: 5 }];
expect(
countBrowserOsVisitsByTimeframe({ browsers: rows, operatingSystems: rows }, windows)
).toEqual(empty);
});
@@ -0,0 +1,42 @@
import type { VisitTimeframe, VisitWindow } from './daily.js';
import type { VisitDetails } from './reports.js';
export type VisitCountByName = { name: string; visits: number };
export type BrowserOsVisits = {
browsers: VisitCountByName[];
operatingSystems: VisitCountByName[];
};
export type BrowserOsVisitsByTimeframe = Partial<Record<VisitTimeframe, BrowserOsVisits>>;
export function countBrowserOsVisitsByTimeframe(
details: Pick<VisitDetails, 'browsers' | 'operatingSystems'> | undefined,
windows: Array<{ timeframe: VisitTimeframe; window: VisitWindow }>
): BrowserOsVisitsByTimeframe {
const browserOsVisitsByTimeframe: BrowserOsVisitsByTimeframe = {};
if (details === undefined) return browserOsVisitsByTimeframe;
for (const { timeframe, window } of windows) {
browserOsVisitsByTimeframe[timeframe] = {
browsers: countVisitsByName(details.browsers, window),
operatingSystems: countVisitsByName(details.operatingSystems, window)
};
}
return browserOsVisitsByTimeframe;
}
function countVisitsByName(
rows: VisitDetails['browsers'],
window: VisitWindow
): VisitCountByName[] {
const from = Date.parse(window.from);
const asOf = Date.parse(window.asOf);
const counts = new Map<string, number>();
for (const row of rows) {
const start = Date.parse(`${row.day}T00:00:00.000Z`);
if (start >= asOf || start + 86_400_000 <= from || row.validVisits === 0) continue;
counts.set(row.name, (counts.get(row.name) ?? 0) + row.validVisits);
}
return [...counts]
.map(([name, visits]) => ({ name, visits }))
.sort((left, right) => right.visits - left.visits || left.name.localeCompare(right.name, 'en'));
}
+47 -4
View File
@@ -14,15 +14,15 @@ const summary: OverviewSummary = {
domains: []
};
const details: VisitDetails = {
version: 1,
version: 2,
scope,
period,
daily: visitDays(period).map((day, index) => ({
...day,
...(index === 0 ? summary.totals : zeroVisitTotals())
})),
browsers: [{ family: 'Firefox', ...summary.totals }],
operatingSystems: [{ family: 'Linux', ...summary.totals }]
browsers: [{ day: period.from.slice(0, 10), name: 'Firefox', ...summary.totals }],
operatingSystems: [{ day: period.from.slice(0, 10), name: 'Linux', ...summary.totals }]
};
test('readers decode their self-contained document independently', () => {
@@ -100,7 +100,7 @@ test('details reader checks daily instants, window safety, counts and scope', ()
'Invalid analytics report'
);
}
expect(() => parseVisitDetails(JSON.stringify({ ...details, version: 2 }), scope)).toThrow(
expect(() => parseVisitDetails(JSON.stringify({ ...details, version: 1 }), scope)).toThrow(
'Invalid analytics report'
);
expect(() =>
@@ -109,6 +109,49 @@ test('details reader checks daily instants, window safety, counts and scope', ()
expect(() => parseVisitDetails('{private', scope)).toThrow('Invalid analytics report');
});
test('details reader requires canonical UTC browser/OS days intersecting the retained reporting period', () => {
for (const day of [
'2026-1-01',
'2026-02-30',
'2026-01-01T00:00:00.000Z',
'2025-12-31',
'2026-04-02'
]) {
const rows = [{ day, name: 'Other', validVisits: 1, invalidVisits: 0 }];
for (const invalid of [
{ ...details, browsers: rows },
{ ...details, operatingSystems: rows }
]) {
expect(() => parseVisitDetails(JSON.stringify(invalid), scope)).toThrow(
'Invalid analytics report'
);
}
}
const midnightPeriod = createVisitPeriod(3, Date.UTC(2026, 3, 1));
expect(() =>
parseVisitDetails(
JSON.stringify({
...details,
period: midnightPeriod,
daily: [],
browsers: [{ day: '2026-04-01', name: 'Other', validVisits: 1, invalidVisits: 0 }]
}),
scope
)
).toThrow('Invalid analytics report');
});
test('details reader accepts safe partial-edge browser/OS rows without redoing producer arithmetic', () => {
const document = {
...details,
browsers: [
{ day: '2026-01-01', name: 'Other', validVisits: 7, invalidVisits: 0 },
{ day: '2026-04-01', name: 'Other', validVisits: 8, invalidVisits: 0 }
]
};
expect(parseVisitDetails(JSON.stringify(document), scope)).toEqual(document);
});
test('reader prevents a base subset from exceeding totals used by page subtraction', () => {
expect(() =>
parseVisitSummary(
+39 -8
View File
@@ -34,15 +34,17 @@ function report(
return {
summary,
details: {
version: 1,
version: 2,
scope,
period,
daily: visitDays(period).map((day, index) => ({
...day,
...(index === 0 ? totals : zeroVisitTotals())
})),
browsers: populated ? [{ family: 'Firefox', ...totals }] : [],
operatingSystems: populated ? [{ family: 'Linux', ...totals }] : []
browsers: populated ? [{ day: period.from.slice(0, 10), name: 'Firefox', ...totals }] : [],
operatingSystems: populated
? [{ day: period.from.slice(0, 10), name: 'Linux', ...totals }]
: []
}
};
}
@@ -88,7 +90,7 @@ test('rejects incompatible pair identities, periods, totals and document version
expect(() => encodeVisitReports([invalid])).toThrow('Invalid analytics report');
}
const wrongVersion = report();
Object.assign(wrongVersion.details, { version: 2 });
Object.assign(wrongVersion.details, { version: 1 });
expect(() => encodeVisitReports([wrongVersion])).toThrow('Invalid analytics report');
});
@@ -133,19 +135,20 @@ test('producer requires gap-free UTC days and correct partial flags including ze
}
});
test('producer checks family sums and duplicate labels against the daily totals', () => {
test('producer checks dated browser/OS sums and duplicate day/name pairs', () => {
const prepared = report();
const day = prepared.details.period.from.slice(0, 10);
for (const details of [
{ ...prepared.details, browsers: [] },
{
...prepared.details,
operatingSystems: [{ family: 'Linux', validVisits: 3, invalidVisits: 0 }]
operatingSystems: [{ day, name: 'Linux', validVisits: 3, invalidVisits: 0 }]
},
{
...prepared.details,
browsers: [
{ family: 'Firefox', validVisits: 1, invalidVisits: 0 },
{ family: 'Firefox', validVisits: 1, invalidVisits: 1 }
{ day, name: 'Firefox', validVisits: 1, invalidVisits: 0 },
{ day, name: 'Firefox', validVisits: 1, invalidVisits: 1 }
]
}
]) {
@@ -155,6 +158,34 @@ test('producer checks family sums and duplicate labels against the daily totals'
}
});
test('producer permits the same browser/OS name on different days but rejects moving counts between days', () => {
const prepared = report();
const first = prepared.details.daily[0];
const second = prepared.details.daily[1];
if (!first || !second) throw new Error('Expected daily fixtures');
first.validVisits = 1;
second.validVisits = 1;
const browserOsCounts = [
{ day: first.start.slice(0, 10), name: 'Other', validVisits: 1, invalidVisits: 1 },
{ day: second.start.slice(0, 10), name: 'Other', validVisits: 1, invalidVisits: 0 }
];
prepared.details.browsers = browserOsCounts;
prepared.details.operatingSystems = browserOsCounts;
expect(encodeVisitReports([prepared])).toHaveLength(1);
const shifted = browserOsCounts.map((row, index) => ({
...row,
invalidVisits: index === 0 ? 0 : 1
}));
for (const details of [
{ ...prepared.details, browsers: shifted },
{ ...prepared.details, operatingSystems: shifted }
]) {
expect(() => encodeVisitReports([{ ...prepared, details }])).toThrow(
'Invalid analytics report'
);
}
});
test('producer rejects base or compact child subsets above totals and duplicate children', () => {
const prepared = report();
for (const summary of [
+19 -7
View File
@@ -53,8 +53,8 @@ export function encodeVisitReports(reports: PreparedVisitReport[]): SerializedVi
checkDailyLayout(details.daily, days);
const totals = sumTotals(details.daily);
if (!equalTotals(summary.totals, totals)) throw invalidReport();
checkFamilies(details.browsers, totals);
checkFamilies(details.operatingSystems, totals);
checkDailyBrowserOsTotals(details.browsers, details.daily);
checkDailyBrowserOsTotals(details.operatingSystems, details.daily);
checkChildren(summary);
}
@@ -80,11 +80,23 @@ function checkDailyLayout(daily: VisitDetails['daily'], days: ReturnType<typeof
}
}
function checkFamilies(rows: VisitDetails['browsers'], totals: VisitTotals): void {
const families = new Set(rows.map(({ family }) => family));
const combined = sumTotals(rows);
if (families.size !== rows.length || !equalTotals(combined, totals)) {
throw invalidReport();
function checkDailyBrowserOsTotals(
rows: VisitDetails['browsers'],
dailyVisits: VisitDetails['daily']
): void {
const seenDayAndNamePairs = new Set<string>();
const countsByDay = new Map<string, VisitTotals[]>();
for (const row of rows) {
const dayAndName = `${row.day}:${row.name}`;
if (seenDayAndNamePairs.has(dayAndName)) throw invalidReport();
seenDayAndNamePairs.add(dayAndName);
const dayCounts = countsByDay.get(row.day) ?? [];
dayCounts.push(row);
countsByDay.set(row.day, dayCounts);
}
for (const day of dailyVisits) {
const totals = sumTotals(countsByDay.get(day.start.slice(0, 10)) ?? []);
if (!equalTotals(totals, day)) throw invalidReport();
}
}
+6
View File
@@ -14,3 +14,9 @@ export type {
PreparedVisitReport,
SerializedVisitReport
} from './reports.js';
export { countBrowserOsVisitsByTimeframe } from './browser-os-visits.js';
export type {
VisitCountByName,
BrowserOsVisits,
BrowserOsVisitsByTimeframe
} from './browser-os-visits.js';
+11 -5
View File
@@ -8,6 +8,11 @@ const path = Type.String({ pattern: '^/' });
FormatRegistry.Set('goto-analytics-utc-instant', isCanonicalInstant);
const instant = Type.String({ format: 'goto-analytics-utc-instant' });
FormatRegistry.Set(
'goto-analytics-utc-day',
(value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && isCanonicalInstant(`${value}T00:00:00.000Z`)
);
const day = Type.String({ format: 'goto-analytics-utc-day' });
export const ReportScopeSchema = Type.Union([
Type.Object({ kind: Type.Literal('overview') }, exact),
@@ -74,9 +79,10 @@ export const VisitSummarySchema = Type.Union([
]);
export type VisitSummary = Static<typeof VisitSummarySchema>;
const familyCounts = Type.Object(
const dailyBrowserOsCounts = Type.Object(
{
family: Type.String({ minLength: 1 }),
day,
name: Type.String({ minLength: 1 }),
validVisits: count,
invalidVisits: count
},
@@ -85,7 +91,7 @@ const familyCounts = Type.Object(
export const VisitDetailsSchema = Type.Object(
{
version: Type.Literal(1),
version: Type.Literal(2),
scope: ReportScopeSchema,
period: ReportPeriodSchema,
daily: Type.Array(
@@ -100,8 +106,8 @@ export const VisitDetailsSchema = Type.Object(
exact
)
),
browsers: Type.Array(familyCounts),
operatingSystems: Type.Array(familyCounts)
browsers: Type.Array(dailyBrowserOsCounts),
operatingSystems: Type.Array(dailyBrowserOsCounts)
},
exact
);
+4
View File
@@ -32,6 +32,10 @@ export function checkDetails(value: unknown): VisitDetails {
throw invalidReport();
}
}
for (const row of [...value.browsers, ...value.operatingSystems]) {
const start = Date.parse(`${row.day}T00:00:00.000Z`);
if (start >= asOf || start + dayMilliseconds <= from) throw invalidReport();
}
return value;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from 'vitest';
import { classifyUserAgent } from './classify-user-agent.js';
test('recognizes Edge as one family before the generic Chrome and Safari tokens', () => {
test('recognizes Edge before the generic Chrome and Safari tokens', () => {
for (const token of ['Edg', 'EdgA', 'EdgiOS', 'Edge']) {
const result = classifyUserAgent(`Mozilla/5.0 Chrome/125.0 Safari/537.36 ${token}/125.0`);
expect(result.browser).toBe('Edge');
+29 -15
View File
@@ -1,20 +1,31 @@
function browserFamily(ua: string): string {
if (ua.includes('edg/') || ua.includes('edga/') || ua.includes('edgios/') || ua.includes('edge/'))
function browserName(userAgent: string): string {
if (
userAgent.includes('edg/') ||
userAgent.includes('edga/') ||
userAgent.includes('edgios/') ||
userAgent.includes('edge/')
)
return 'Edge';
if (ua.includes('opr/') || ua.includes('opera/')) return 'Opera';
if (ua.includes('firefox/') || ua.includes('fxios/')) return 'Firefox';
if (ua.includes('chrome/') || ua.includes('crios/') || ua.includes('chromium/')) return 'Chrome';
if (ua.includes('safari/')) return 'Safari';
if (userAgent.includes('opr/') || userAgent.includes('opera/')) return 'Opera';
if (userAgent.includes('firefox/') || userAgent.includes('fxios/')) return 'Firefox';
if (
userAgent.includes('chrome/') ||
userAgent.includes('crios/') ||
userAgent.includes('chromium/')
)
return 'Chrome';
if (userAgent.includes('safari/')) return 'Safari';
return 'Other';
}
function operatingSystem(ua: string): string {
if (ua.includes('windows')) return 'Windows';
if (ua.includes('android')) return 'Android';
if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ipod')) return 'iOS';
if (ua.includes('macintosh') || ua.includes('mac os x')) return 'macOS';
if (ua.includes('cros ')) return 'ChromeOS';
if (ua.includes('linux')) return 'Linux';
function operatingSystemName(userAgent: string): string {
if (userAgent.includes('windows')) return 'Windows';
if (userAgent.includes('android')) return 'Android';
if (userAgent.includes('iphone') || userAgent.includes('ipad') || userAgent.includes('ipod'))
return 'iOS';
if (userAgent.includes('macintosh') || userAgent.includes('mac os x')) return 'macOS';
if (userAgent.includes('cros ')) return 'ChromeOS';
if (userAgent.includes('linux')) return 'Linux';
return 'Other';
}
@@ -22,6 +33,9 @@ export function classifyUserAgent(userAgent: string | null | undefined): {
browser: string;
os: string;
} {
const ua = userAgent?.toLowerCase() ?? '';
return { browser: browserFamily(ua), os: operatingSystem(ua) };
const normalizedUserAgent = userAgent?.toLowerCase() ?? '';
return {
browser: browserName(normalizedUserAgent),
os: operatingSystemName(normalizedUserAgent)
};
}
+1 -1
View File
@@ -12,5 +12,5 @@ export type {
AggregatedVisitReports,
DailyVisitCounts,
ReportInventory,
ScopedFamilyCounts
DailyBrowserOsCounts
} from './types.js';
+52 -10
View File
@@ -165,15 +165,26 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
scopeKind: 'overview',
domain: null,
path: null,
family: 'Firefox',
day: '2026-01-31',
name: 'Firefox',
validVisits: 1,
invalidVisits: 0
},
{
scopeKind: 'overview',
domain: null,
path: null,
day: '2026-02-01',
name: 'Firefox',
validVisits: 0,
invalidVisits: 3
},
{
scopeKind: 'overview',
domain: null,
path: null,
family: 'Other',
day: '2026-02-01',
name: 'Other',
validVisits: 0,
invalidVisits: 1
},
@@ -181,7 +192,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
scopeKind: 'overview',
domain: null,
path: null,
family: 'Safari',
day: '2026-02-01',
name: 'Safari',
validVisits: 1,
invalidVisits: 0
}
@@ -191,15 +203,26 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
scopeKind: 'overview',
domain: null,
path: null,
family: 'Linux',
day: '2026-01-31',
name: 'Linux',
validVisits: 1,
invalidVisits: 0
},
{
scopeKind: 'overview',
domain: null,
path: null,
day: '2026-02-01',
name: 'Linux',
validVisits: 0,
invalidVisits: 2
},
{
scopeKind: 'overview',
domain: null,
path: null,
family: 'Other',
day: '2026-02-01',
name: 'Other',
validVisits: 0,
invalidVisits: 1
},
@@ -207,7 +230,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
scopeKind: 'overview',
domain: null,
path: null,
family: 'Windows',
day: '2026-02-01',
name: 'Windows',
validVisits: 0,
invalidVisits: 1
},
@@ -215,7 +239,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
scopeKind: 'overview',
domain: null,
path: null,
family: 'macOS',
day: '2026-02-01',
name: 'macOS',
validVisits: 1,
invalidVisits: 0
}
@@ -223,8 +248,24 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
expect(
result.browsers.filter(({ scopeKind, path }) => scopeKind === 'link' && path === '/')
).toEqual([
{ scopeKind: 'link', domain, path: '/', family: 'Firefox', validVisits: 0, invalidVisits: 1 },
{ scopeKind: 'link', domain, path: '/', family: 'Safari', validVisits: 1, invalidVisits: 0 }
{
scopeKind: 'link',
domain,
path: '/',
day: '2026-02-01',
name: 'Firefox',
validVisits: 0,
invalidVisits: 1
},
{
scopeKind: 'link',
domain,
path: '/',
day: '2026-02-01',
name: 'Safari',
validVisits: 1,
invalidVisits: 0
}
]);
expect(
result.operatingSystems.filter(
@@ -235,7 +276,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn
scopeKind: 'link',
domain: otherDomain,
path: '/docs',
family: 'Windows',
day: '2026-02-01',
name: 'Windows',
validVisits: 0,
invalidVisits: 1
}
+9 -9
View File
@@ -4,7 +4,7 @@ import type {
AggregatedVisitReports,
DailyVisitCounts,
ReportInventory,
ScopedFamilyCounts
DailyBrowserOsCounts
} from './types.js';
export async function aggregateVisitReports(
@@ -30,19 +30,19 @@ export async function aggregateVisitReports(
GROUP BY scope_kind, domain, path, day
ORDER BY scope_kind, domain, path, day
`.execute(transaction);
const browsers = await sql<ScopedFamilyCounts>`
const browsers = await sql<DailyBrowserOsCounts>`
${scopes}
SELECT ${identity}, browser AS family, ${counts}
SELECT ${identity}, date(visited_at / 1000.0, 'unixepoch') AS day, browser AS name, ${counts}
FROM scoped_visits
GROUP BY scope_kind, domain, path, browser
ORDER BY scope_kind, domain, path, family
GROUP BY scope_kind, domain, path, day, browser
ORDER BY scope_kind, domain, path, day, name
`.execute(transaction);
const operatingSystems = await sql<ScopedFamilyCounts>`
const operatingSystems = await sql<DailyBrowserOsCounts>`
${scopes}
SELECT ${identity}, os AS family, ${counts}
SELECT ${identity}, date(visited_at / 1000.0, 'unixepoch') AS day, os AS name, ${counts}
FROM scoped_visits
GROUP BY scope_kind, domain, path, os
ORDER BY scope_kind, domain, path, family
GROUP BY scope_kind, domain, path, day, os
ORDER BY scope_kind, domain, path, day, name
`.execute(transaction);
return {
daily: daily.rows,
@@ -56,15 +56,17 @@ function preparedReport(
return {
summary,
details: {
version: 1,
version: 2,
scope,
period,
daily: visitDays(period).map((day, index) => ({
...day,
...(index === 0 ? totals : zeroVisitTotals())
})),
browsers: populated ? [{ family: 'Firefox', ...totals }] : [],
operatingSystems: populated ? [{ family: 'Linux', ...totals }] : []
browsers: populated ? [{ day: period.from.slice(0, 10), name: 'Firefox', ...totals }] : [],
operatingSystems: populated
? [{ day: period.from.slice(0, 10), name: 'Linux', ...totals }]
: []
}
};
}
@@ -150,7 +152,7 @@ test('overview details reject malformed or mismatched domain scope, policy and w
await publishVisitReports(db, encodeVisitReports([overview, current]));
for (const details of [
{},
{ ...current.details, version: 2 },
{ ...current.details, version: 1 },
overview.details,
{ ...current.details, scope: { kind: 'domain', domain: 'wrong.example.test' } },
preparedReport(domainScope, asOf + 86_400_000).details,
+4 -3
View File
@@ -14,10 +14,11 @@ export type DailyVisitCounts = DiscriminatedAggregateScope &
baseInvalidVisits: number;
};
export type ScopedFamilyCounts = DiscriminatedAggregateScope & VisitTotals & { family: string };
export type DailyBrowserOsCounts = DiscriminatedAggregateScope &
VisitTotals & { day: string; name: string };
export interface AggregatedVisitReports {
daily: DailyVisitCounts[];
browsers: ScopedFamilyCounts[];
operatingSystems: ScopedFamilyCounts[];
browsers: DailyBrowserOsCounts[];
operatingSystems: DailyBrowserOsCounts[];
}