diff --git a/.changeset/browser-os-breakdowns.md b/.changeset/browser-os-breakdowns.md
new file mode 100644
index 0000000..21de864
--- /dev/null
+++ b/.changeset/browser-os-breakdowns.md
@@ -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.
diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts
index 4390b6b..56e9538 100644
--- a/apps/web/e2e/auth.e2e.ts
+++ b/apps/web/e2e/auth.e2e.ts
@@ -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);
diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.mjs
index c4ad354..22e54ea 100644
--- a/apps/web/e2e/server.mjs
+++ b/apps/web/e2e/server.mjs
@@ -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' },
diff --git a/apps/web/src/lib/components/admin/BrowserOsBreakdowns.svelte b/apps/web/src/lib/components/admin/BrowserOsBreakdowns.svelte
new file mode 100644
index 0000000..5c159c7
--- /dev/null
+++ b/apps/web/src/lib/components/admin/BrowserOsBreakdowns.svelte
@@ -0,0 +1,135 @@
+
+
+{#snippet visitCountsCard(title: string, nameColumnTitle: string, visitCounts: VisitCountByName[])}
+
+ {title}
+ {#if totalVisits === 0}
+ No visits in this period.
+ {:else}
+
+
+
+ | {nameColumnTitle} |
+ Visits |
+ Share |
+
+
+
+ {#each visitCounts as visitCount (visitCount.name)}
+ {@const share = visitCount.visits / totalVisits}
+
+ |
+ {visitCount.name}
+
+
+
+ |
+ {formatCount(visitCount.visits)} |
+ {percentFormat.format(share)} |
+
+ {/each}
+
+
+ {/if}
+
+{/snippet}
+
+
+ {@render visitCountsCard('Browsers', 'Browser', browserOsVisits.browsers)}
+ {@render visitCountsCard(
+ 'Operating systems',
+ 'Operating system',
+ browserOsVisits.operatingSystems
+ )}
+
+
+
diff --git a/apps/web/src/lib/server/visit-reports.test.ts b/apps/web/src/lib/server/visit-reports.test.ts
index 04ccb6c..cc522c7 100644
--- a/apps/web/src/lib/server/visit-reports.test.ts
+++ b/apps/web/src/lib/server/visit-reports.test.ts
@@ -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;
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/+page.server.ts b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/+page.server.ts
index a429e38..23bcfbc 100644
--- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/+page.server.ts
+++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/+page.server.ts
@@ -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> = {};
for (const { timeframe } of windows) {
@@ -71,6 +76,7 @@ export const load = (async ({ locals, params }) => {
linkCount: links.length,
timeframes,
countsByTimeframe,
+ browserOsVisitsByTimeframe,
invalidClicksByTimeframe,
chart,
base: {
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/+page.svelte b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/+page.svelte
index 4e68dd4..71dea04 100644
--- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/+page.svelte
+++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/+page.svelte
@@ -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);
{/if}
+ {#if browserOsVisits && visits !== null}
+
+ {/if}
Links
{#if data.links.length === 0}No links configured.
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.server.ts b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.server.ts
index e3c91f7..b67565f 100644
--- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.server.ts
+++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.server.ts
@@ -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;
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.svelte b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.svelte
index d8a4439..900858a 100644
--- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.svelte
+++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.svelte
@@ -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);
{/if}
+ {#if browserOsVisits && visits !== null}
+
+ {/if}
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/page.server.test.ts b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/page.server.test.ts
index ccc46b3..c9d9b58 100644
--- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/page.server.test.ts
+++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/page.server.test.ts
@@ -40,19 +40,25 @@ const counts: Record = {
'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([]);
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/page.server.test.ts b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/page.server.test.ts
index a17081b..681203c 100644
--- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/page.server.test.ts
+++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/page.server.test.ts
@@ -37,17 +37,21 @@ function details(scope: ReportScope, counts: Record):
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({});
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/page.server.test.ts b/apps/web/src/routes/(admin)/(auth)/admin/page.server.test.ts
index 53e80c1..d44ba1e 100644
--- a/apps/web/src/routes/(admin)/(auth)/admin/page.server.test.ts
+++ b/apps/web/src/routes/(admin)/(auth)/admin/page.server.test.ts
@@ -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
};
}
diff --git a/apps/worker/src/services/build-visit-reports.test.ts b/apps/worker/src/services/build-visit-reports.test.ts
index 3dcd181..3b2f1f9 100644
--- a/apps/worker/src/services/build-visit-reports.test.ts
+++ b/apps/worker/src/services/build-visit-reports.test.ts
@@ -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(
diff --git a/apps/worker/src/services/build-visit-reports.ts b/apps/worker/src/services/build-visit-reports.ts
index c4a3f23..4349826 100644
--- a/apps/worker/src/services/build-visit-reports.ts
+++ b/apps/worker/src/services/build-visit-reports.ts
@@ -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
): 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
}));
diff --git a/apps/worker/src/services/prepare-visit-reports.test.ts b/apps/worker/src/services/prepare-visit-reports.test.ts
index 1470076..1869fc2 100644
--- a/apps/worker/src/services/prepare-visit-reports.test.ts
+++ b/apps/worker/src/services/prepare-visit-reports.test.ts
@@ -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);
diff --git a/packages/analytics/src/browser-os-visits.test.ts b/packages/analytics/src/browser-os-visits.test.ts
new file mode 100644
index 0000000..d11ae3f
--- /dev/null
+++ b/packages/analytics/src/browser-os-visits.test.ts
@@ -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 = {
+ 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);
+});
diff --git a/packages/analytics/src/browser-os-visits.ts b/packages/analytics/src/browser-os-visits.ts
new file mode 100644
index 0000000..bc82d8a
--- /dev/null
+++ b/packages/analytics/src/browser-os-visits.ts
@@ -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>;
+
+export function countBrowserOsVisitsByTimeframe(
+ details: Pick | 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();
+ 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'));
+}
diff --git a/packages/analytics/src/decode.test.ts b/packages/analytics/src/decode.test.ts
index dd91bf3..3bb18ef 100644
--- a/packages/analytics/src/decode.test.ts
+++ b/packages/analytics/src/decode.test.ts
@@ -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(
diff --git a/packages/analytics/src/encode.test.ts b/packages/analytics/src/encode.test.ts
index bd657fe..a99022e 100644
--- a/packages/analytics/src/encode.test.ts
+++ b/packages/analytics/src/encode.test.ts
@@ -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 [
diff --git a/packages/analytics/src/encode.ts b/packages/analytics/src/encode.ts
index 4f9ba12..dbe4d52 100644
--- a/packages/analytics/src/encode.ts
+++ b/packages/analytics/src/encode.ts
@@ -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 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();
+ const countsByDay = new Map();
+ 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();
}
}
diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts
index f86bb4e..2b39487 100644
--- a/packages/analytics/src/index.ts
+++ b/packages/analytics/src/index.ts
@@ -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';
diff --git a/packages/analytics/src/reports.ts b/packages/analytics/src/reports.ts
index 75fb328..49b9384 100644
--- a/packages/analytics/src/reports.ts
+++ b/packages/analytics/src/reports.ts
@@ -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;
-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
);
diff --git a/packages/analytics/src/validation.ts b/packages/analytics/src/validation.ts
index f786483..33c7ccc 100644
--- a/packages/analytics/src/validation.ts
+++ b/packages/analytics/src/validation.ts
@@ -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;
}
diff --git a/packages/db/src/classify-user-agent.test.ts b/packages/db/src/classify-user-agent.test.ts
index ed5704e..0cc5ce3 100644
--- a/packages/db/src/classify-user-agent.test.ts
+++ b/packages/db/src/classify-user-agent.test.ts
@@ -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');
diff --git a/packages/db/src/classify-user-agent.ts b/packages/db/src/classify-user-agent.ts
index 6c1b8a2..e669292 100644
--- a/packages/db/src/classify-user-agent.ts
+++ b/packages/db/src/classify-user-agent.ts
@@ -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)
+ };
}
diff --git a/packages/db/src/visit-reports/index.ts b/packages/db/src/visit-reports/index.ts
index ff165f6..a9e6ca7 100644
--- a/packages/db/src/visit-reports/index.ts
+++ b/packages/db/src/visit-reports/index.ts
@@ -12,5 +12,5 @@ export type {
AggregatedVisitReports,
DailyVisitCounts,
ReportInventory,
- ScopedFamilyCounts
+ DailyBrowserOsCounts
} from './types.js';
diff --git a/packages/db/src/visit-reports/queries.test.ts b/packages/db/src/visit-reports/queries.test.ts
index edee7fb..9bfaa23 100644
--- a/packages/db/src/visit-reports/queries.test.ts
+++ b/packages/db/src/visit-reports/queries.test.ts
@@ -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
}
diff --git a/packages/db/src/visit-reports/queries.ts b/packages/db/src/visit-reports/queries.ts
index 93895dd..20be43d 100644
--- a/packages/db/src/visit-reports/queries.ts
+++ b/packages/db/src/visit-reports/queries.ts
@@ -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`
+ const browsers = await sql`
${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`
+ const operatingSystems = await sql`
${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,
diff --git a/packages/db/src/visit-reports/storage.test.ts b/packages/db/src/visit-reports/storage.test.ts
index 3f0282d..8564ec4 100644
--- a/packages/db/src/visit-reports/storage.test.ts
+++ b/packages/db/src/visit-reports/storage.test.ts
@@ -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,
diff --git a/packages/db/src/visit-reports/types.ts b/packages/db/src/visit-reports/types.ts
index 6abeacc..d4589c3 100644
--- a/packages/db/src/visit-reports/types.ts
+++ b/packages/db/src/visit-reports/types.ts
@@ -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[];
}