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} + + + + + + + + + + {#each visitCounts as visitCount (visitCount.name)} + {@const share = visitCount.visits / totalVisits} + + + + + + {/each} + +
{nameColumnTitle}VisitsShare
+ {visitCount.name} + + {formatCount(visitCount.visits)}{percentFormat.format(share)}
+ {/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[]; }