feat(admin): add browser and OS breakdowns #22
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
@@ -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>
|
||||
+26
-4
@@ -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(
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -12,5 +12,5 @@ export type {
|
||||
AggregatedVisitReports,
|
||||
DailyVisitCounts,
|
||||
ReportInventory,
|
||||
ScopedFamilyCounts
|
||||
DailyBrowserOsCounts
|
||||
} from './types.js';
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in new issue
Block a user