diff --git a/.changeset/admin-root-settings.md b/.changeset/admin-root-settings.md new file mode 100644 index 0000000..6f8c52a --- /dev/null +++ b/.changeset/admin-root-settings.md @@ -0,0 +1,9 @@ +--- +'@goto/web': minor +'@goto/worker': minor +'@goto/cli': minor +--- + +Use one `(root)` inventory row to reach Root details and remove redundant Root summary navigation. Expose root/fallback response settings and named-link settings, inheritance, schedules and tags without changing existing URLs. + +Count every tracked `/` request as a valid Root visit, independent of response status. Apply that meaning to legacy Root visits during report aggregation without rewriting raw rows. Details version 3 keeps totals, charts and browser/OS breakdowns consistent; older prepared details stay unavailable until normal report refresh. Invalid clicks remain limited to non-root traffic. diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index d700eb8..7ad320e 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -34,7 +34,7 @@ test('shared credentials protect admin pages and sign-out removes access', async .getByRole('region', { name: 'Visits', exact: true }) .locator('.admin-stat-value'); await expect(page.getByRole('region', { name: 'Domains', exact: true })).toContainText('2'); - await expect(visitsValue).toHaveText('2'); + await expect(visitsValue).toHaveText('3'); await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([ 'Domains', 'Links', @@ -57,18 +57,18 @@ test('shared credentials protect admin pages and sign-out removes access', async await expect(chartTable).toBeVisible(); await expect(chartTable).toHaveAccessibleName('Visits'); await expect(chartTable.getByRole('columnheader')).toHaveText(['Period (UTC)', 'Visits']); - await expect(chartTable.getByRole('row', { name: 'Total 2', exact: true })).toBeVisible(); + await expect(chartTable.getByRole('row', { name: 'Total 3', exact: true })).toBeVisible(); await expect(chartTable).not.toContainText('(partial)'); await expect(trend.getByRole('region', { name: 'Visit data', exact: true })).toHaveAttribute( 'tabindex', '0' ); await expect(graph).toBeVisible(); - await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 2\./); + await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 3\./); const domainLink = page.getByRole('link', { name: 'go.example.org', exact: true }); const domainRow = page.getByRole('listitem').filter({ has: domainLink }); const domainCounts = domainRow.locator('.admin-inventory-number'); - await expect(domainCounts).toHaveText(['6', '1']); + await expect(domainCounts).toHaveText(['6', '2']); await expect(domainRow.locator('.admin-inventory-label').first()).toHaveCSS( 'clip-path', 'inset(50%)' @@ -87,33 +87,33 @@ test('shared credentials protect admin pages and sign-out removes access', async 'aria-pressed', 'true' ); - await expect(visitsValue).toHaveText('1'); - await expect(domainCounts).toHaveText(['6', '0']); - await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 1\./); + await expect(visitsValue).toHaveText('2'); + await expect(domainCounts).toHaveText(['6', '1']); + await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 2\./); await timeframe.getByRole('button', { name: '3 months', exact: true }).click(); await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( 'aria-pressed', 'true' ); - await expect(visitsValue).toHaveText('3'); - await expect(domainCounts).toHaveText(['6', '2']); - await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./); + await expect(visitsValue).toHaveText('4'); + await expect(domainCounts).toHaveText(['6', '3']); + await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 4\./); await page.goBack(); await expect(timeframe.getByRole('button', { name: '7 days', exact: true })).toHaveAttribute( 'aria-pressed', 'true' ); - await expect(visitsValue).toHaveText('1'); - await expect(domainCounts).toHaveText(['6', '0']); - await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 1\./); + await expect(visitsValue).toHaveText('2'); + await expect(domainCounts).toHaveText(['6', '1']); + await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 2\./); await page.goForward(); await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( 'aria-pressed', 'true' ); - await expect(visitsValue).toHaveText('3'); - await expect(domainCounts).toHaveText(['6', '2']); - await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./); + await expect(visitsValue).toHaveText('4'); + await expect(domainCounts).toHaveText(['6', '3']); + await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 4\./); page.off('request', recordSelectionRequest); expect(selectionRequests).toEqual([]); await page.reload(); @@ -121,8 +121,8 @@ test('shared credentials protect admin pages and sign-out removes access', async 'aria-pressed', 'true' ); - await expect(visitsValue).toHaveText('3'); - await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./); + await expect(visitsValue).toHaveText('4'); + await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 4\./); expect(navigationWarnings).toEqual([]); await domainLink.click(); await expect(page.getByRole('heading', { name: 'Domain', exact: true })).toBeVisible(); @@ -144,24 +144,28 @@ test('shared credentials protect admin pages and sign-out removes access', async await expect(overviewLink).toHaveAttribute('href', '/admin?period=all'); await expect(page.getByRole('link', { name: 'Overview', exact: true })).toHaveCount(0); await expect(graph).toHaveAttribute('aria-busy', 'false'); - await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 2\./); + await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./); await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( 'aria-pressed', 'true' ); - await expect(visitsValue).toHaveText('2'); + await expect(visitsValue).toHaveText('3'); await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([ 'Links', 'Visits', - 'Invalid clicks', - 'Base visits' + 'Invalid clicks' ]); await expect( page.getByRole('region', { name: 'Invalid clicks', exact: true }).locator('.admin-stat-value') ).toHaveText('1'); const linksInventory = page.getByRole('region', { name: 'Links on go.example.org', exact: true }); const domainDetails = page.getByRole('region', { name: 'Domain details', exact: true }); - const baseVisitsLink = page.getByRole('link', { name: 'Base visits', exact: true }); + const rootRow = linksInventory.getByRole('listitem').filter({ + has: page.getByRole('link', { name: '(root)', exact: true }) + }); + const rootLink = rootRow.getByRole('link', { name: '(root)', exact: true }); + await expect(rootRow.locator('.admin-inventory-secondary')).toHaveText('[not found]'); + await expect(rootRow.locator('.admin-inventory-state')).toHaveText('Active'); const columns = ['Path', 'Destination', 'State', 'Visits']; await expect(linksInventory.locator('.admin-inventory-heading > span')).toHaveText(columns); const retainedLink = linksInventory @@ -180,18 +184,18 @@ test('shared credentials protect admin pages and sign-out removes access', async .getByRole('listbox', { name: 'Timeframe', exact: true }) .getByRole('option', { name: '30 days', exact: true }) .click(); - await expect(visitsValue).toHaveText('1'); + await expect(visitsValue).toHaveText('2'); await expect(retainedVisits).toHaveText('0'); - await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 1\./); + await expect(graph).toHaveAccessibleName(/^Visits by day\. Total: 2\./); expect( await linksInventory.evaluate((element) => element.scrollWidth <= element.clientWidth) ).toBe(true); await page.setViewportSize({ width: 1280, height: 720 }); await timeframe.getByRole('button', { name: '3 months', exact: true }).click(); - await expect(visitsValue).toHaveText('2'); + await expect(visitsValue).toHaveText('3'); await expect(retainedVisits).toHaveText('1'); - await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 2\./); - expect(await cardSizes()).toEqual(overviewCardSizes); + await expect(graph).toHaveAccessibleName(/^Visits by week\. Total: 3\./); + expect(await cardSizes()).toEqual(overviewCardSizes.slice(0, 3)); const historicalInvalid = linksInventory .getByRole('listitem') .filter({ has: page.getByRole('link', { name: '/a/b', exact: true }) }); @@ -199,29 +203,22 @@ test('shared credentials protect admin pages and sign-out removes access', async await expect( domainDetails.getByRole('heading', { name: 'Domain details', exact: true }) ).toBeVisible(); - await expect(domainDetails.locator('dt')).toHaveText([ - 'Domain', - 'Base destination', - 'Base status' - ]); + await expect(domainDetails.locator('dt')).toHaveText(['Domain', 'Root', 'Fallback']); await expect(domainDetails.locator('dd')).toHaveText([ 'go.example.org', - '404 Not found', - 'Fallback' + 'Uses fallback', + '404 Not found' ]); - await expect( - page.getByRole('region', { name: 'Base visits', exact: true }).locator('.admin-stat-value') - ).toHaveText('0'); + await expect(rootRow.locator('.admin-inventory-number')).toHaveText('1'); + await expect(domainDetails.getByRole('link', { name: 'Root', exact: true })).toHaveCount(0); + await expect(domainDetails.getByRole('link', { name: 'Fallback', exact: true })).toHaveCount(0); await expect( linksInventory.getByText('https://example.org/first-domain', { exact: true }) ).toBeVisible(); await expect( linksInventory.getByRole('link', { name: 'https://example.org/first-domain', exact: true }) ).toHaveCount(0); - await expect(baseVisitsLink).toHaveAttribute( - 'href', - '/admin/domains/go.example.org/links?period=all' - ); + await expect(rootLink).toHaveAttribute('href', '/admin/domains/go.example.org/links?period=all'); const linkDetails = page.getByRole('region', { name: 'Link details', exact: true }); const domainDetailsLink = linkDetails.getByRole('link', { name: 'go.example.org', exact: true }); @@ -233,7 +230,26 @@ test('shared credentials protect admin pages and sign-out removes access', async await expect( linkDetails.getByRole('heading', { name: 'Link details', exact: true }) ).toBeVisible(); - await expect(linkDetails.locator('dt')).toHaveText(['Domain', 'Path', 'Destination']); + await expect(linkDetails.locator('dt')).toHaveText([ + 'Domain', + 'Path', + 'Destination', + 'HTTP status', + 'Forward query', + 'Starts', + 'Expires', + 'Tags' + ]); + await expect(linkDetails.locator('time').first()).toBeVisible(); + await expect(linkDetails.locator('time').first()).toHaveAttribute( + 'datetime', + '2020-01-01T00:00:00Z' + ); + await expect(linkDetails.locator('time').last()).toHaveAttribute( + 'datetime', + '2100-01-01T00:00:00Z' + ); + await expect(linkDetails.getByText('work, docs', { exact: true })).toBeVisible(); await expect(linkDetails.locator('dd').nth(1)).toHaveText('/docs'); await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText(['Visits', 'Status']); await expect(page.getByRole('region', { name: 'Status', exact: true })).toContainText('Active'); @@ -281,17 +297,27 @@ test('shared credentials protect admin pages and sign-out removes access', async await expect(visitsValue).toHaveText('0'); await domainDetailsLink.click(); - await baseVisitsLink.click(); + await rootLink.click(); await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links?period=all`); - await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible(); - await expect(page).toHaveTitle('Link — go.example.org/ — Goto'); - await expect(visitsValue).toHaveText('0'); - await domainDetailsLink.click(); + await expect(page.getByRole('heading', { name: 'Root', exact: true })).toBeVisible(); + await expect(page).toHaveTitle('Root — go.example.org — Goto'); + await expect(visitsValue).toHaveText('1'); + await expect(page.getByRole('region', { name: 'Status', exact: true })).toHaveCount(0); + const rootDetails = page.getByRole('region', { name: 'Root details', exact: true }); + await expect(rootDetails.locator('dd')).toHaveText([ + 'go.example.org', + 'go.example.org/', + 'Uses fallback' + ]); + await expect(page.getByRole('region', { name: 'Fallback', exact: true })).toContainText( + '404 Not found' + ); + await rootDetails.getByRole('link', { name: 'go.example.org', exact: true }).click(); await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( 'aria-pressed', 'true' ); - await expect(visitsValue).toHaveText('2'); + await expect(visitsValue).toHaveText('3'); await page .getByRole('navigation', { name: 'Administration', exact: true }) @@ -356,7 +382,12 @@ test.describe('analytics disabled', () => { ).toHaveText(['Path', 'Destination', 'State']); await expect( page.getByRole('region', { name: 'Domain details', exact: true }).locator('dt') - ).toHaveText(['Domain', 'Base destination', 'Base status']); + ).toHaveText(['Domain', 'Root', 'Fallback']); + await expect( + page + .getByRole('region', { name: 'Links on go.example.org', exact: true }) + .getByRole('link', { name: '(root)', exact: true }) + ).toBeVisible(); 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); diff --git a/apps/web/e2e/server.js b/apps/web/e2e/server.js index b493f27..d7094fb 100644 --- a/apps/web/e2e/server.js +++ b/apps/web/e2e/server.js @@ -34,7 +34,12 @@ const configuration = { '/_app/version.json': { url: 'https://example.org/public-asset' }, '/a/b': { url: 'https://example.org/decoded' }, '/a%2Fb': { url: 'https://example.org/literal-percent' }, - '/docs': { url: 'https://example.org/first-domain' }, + '/docs': { + url: 'https://example.org/first-domain', + valid_from: '2020-01-01T00:00:00Z', + valid_until: '2100-01-01T00:00:00Z', + tags: ['work', 'docs'] + }, '/healthz': { url: 'https://example.org/public-health' } } }, @@ -70,6 +75,7 @@ async function seedVisits() { os: 'Linux' }, { domain: 'go.example.org', path: '/a/b', outcome: 'invalid' }, + // Legacy Root classification exercises report normalization without rewriting raw visits. { domain: 'go.example.org', path: '/', outcome: 'invalid' }, { domain: 'go.example.org', diff --git a/apps/web/e2e/visits.e2e.ts b/apps/web/e2e/visits.e2e.ts index 1961da1..bdae74d 100644 --- a/apps/web/e2e/visits.e2e.ts +++ b/apps/web/e2e/visits.e2e.ts @@ -75,7 +75,7 @@ test('the compiled writer persists outcomes, isolates locks and drains visits be ) .all('go.example.org', '/', '/missing') ).toEqual([ - { path: '/', outcome: 'invalid' }, + { path: '/', outcome: 'valid' }, { path: '/missing', outcome: 'invalid' } ]); } finally { diff --git a/apps/web/src/lib/components/admin/LinkInventory.svelte b/apps/web/src/lib/components/admin/LinkInventory.svelte index 9af93c3..68a02f6 100644 --- a/apps/web/src/lib/components/admin/LinkInventory.svelte +++ b/apps/web/src/lib/components/admin/LinkInventory.svelte @@ -38,10 +38,12 @@ - {link.path} - {link.url ?? '404 Not found'} + {link.path === '/' ? '(root)' : link.path} + {link.url ?? '[not found]'} {#if link.state === 'active'}Active {:else if link.state === 'disabled'}Disabled diff --git a/apps/web/src/lib/components/admin/format.test.ts b/apps/web/src/lib/components/admin/format.test.ts index 9aa3880..e92645d 100644 --- a/apps/web/src/lib/components/admin/format.test.ts +++ b/apps/web/src/lib/components/admin/format.test.ts @@ -1,5 +1,10 @@ import { expect, test } from 'vitest'; -import { formatUpdated, formatUpdatedCompact } from './format.js'; +import { formatUpdated, formatUpdatedCompact, formatUtc } from './format.js'; + +test('formats detail timestamps in UTC with seconds', () => { + expect(formatUtc('2026-11-06T22:03:48Z')).toBe('Nov 6, 2026, 22:03:48 UTC'); + expect(formatUtc('2026-11-06T17:03:48-05:00')).toBe('Nov 6, 2026, 22:03:48 UTC'); +}); test('formats the snapshot update time in UTC', () => { const updatedAt = Date.parse('2026-11-06T22:03:00Z'); diff --git a/apps/web/src/lib/components/admin/format.ts b/apps/web/src/lib/components/admin/format.ts index 1733b12..ac2da32 100644 --- a/apps/web/src/lib/components/admin/format.ts +++ b/apps/web/src/lib/components/admin/format.ts @@ -1,7 +1,18 @@ +const utcFormatter = new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeStyle: 'medium', + hourCycle: 'h23', + timeZone: 'UTC' +}); + export function formatCount(value: number | null): string { return value === null ? '—' : value.toLocaleString('en'); } +export function formatUtc(value: string): string { + return `${utcFormatter.format(new Date(value))} UTC`; +} + export function formatUpdated(value: number | null): string { if (value === null) return ''; const formatter = new Intl.DateTimeFormat('en', { diff --git a/apps/web/src/lib/server/redirect.test.ts b/apps/web/src/lib/server/redirect.test.ts index 27177cc..e4ad0c8 100644 --- a/apps/web/src/lib/server/redirect.test.ts +++ b/apps/web/src/lib/server/redirect.test.ts @@ -315,20 +315,20 @@ describe('validity and fallback', () => { expect(missing.visit).toMatchObject({ path: '/home', outcome: 'invalid' }); }); - test('an unconfigured root uses the fallback response but remains an invalid base visit', () => { + test('a root request using fallback is still a valid Root visit', () => { const domain = preparedDomain({}, fallback); const { response, visit } = resolveRedirect(domain, host, '/?incoming=ignored', 'GET'); expect(response.status).toBe(302); expect(response.headers.get('location')).toBe(fallbackUrl); - expect(visit).toMatchObject({ path: '/', outcome: 'invalid' }); + expect(visit).toMatchObject({ path: '/', outcome: 'valid' }); }); - test('an unconfigured root remains a base visit with an invalid outcome', () => { + test('a root request returning 404 is still a valid Root visit', () => { const domain = preparedDomain(); const { response, visit } = resolveRedirect(domain, host, '/', 'GET'); expect(response.status).toBe(404); - expect(visit).toMatchObject({ path: '/', outcome: 'invalid' }); + expect(visit).toMatchObject({ path: '/', outcome: 'valid' }); }); test('default fallback options preserve the URL without appending the incoming path or query', () => { @@ -421,22 +421,22 @@ describe('query forwarding', () => { describe('direct self-loops', () => { test.each([ - ['/', 'https://GO.EXAMPLE.TEST:443/?changed=one#fragment'], - ['/%64ocs?incoming=one', 'https://go.example.test/docs?different=two'], - ['/a%2Fb', 'https://go.example.test/a/b#fragment'], - ['/docs', 'http://go.example.test:80/docs'], - ['/docs', 'https:///go.example.test/docs'] - ])('runtime blocks a direct fallback loop: %s -> %s', (target, url) => { + ['/', 'https://GO.EXAMPLE.TEST:443/?changed=one#fragment', 'valid'], + ['/%64ocs?incoming=one', 'https://go.example.test/docs?different=two', 'invalid'], + ['/a%2Fb', 'https://go.example.test/a/b#fragment', 'invalid'], + ['/docs', 'http://go.example.test:80/docs', 'invalid'], + ['/docs', 'https:///go.example.test/docs', 'invalid'] + ])('runtime blocks a direct fallback loop: %s -> %s', (target, url, expectedOutcome) => { const domain = preparedDomain({}, preparedResponse({ url, forward_query: true })); const { response, visit } = resolveRedirect(domain, host, target, 'GET'); expect(response.status).toBe(404); - expect(visit?.outcome).toBe('invalid'); + expect(visit?.outcome).toBe(expectedOutcome); expect(response.headers.has('location')).toBe(false); expect(response.headers.get('cache-control')).toBe('no-store'); }); - test('a direct root loop returns an invalid 404 without trying fallback', () => { + test('a Root loop is blocked with 404 without making the Root request invalid', () => { const root = preparedResponse({ url: 'https://GO.EXAMPLE.TEST:443/?owned=old#section', forward_query: true @@ -447,7 +447,7 @@ describe('direct self-loops', () => { expect(response.status).toBe(404); expect(response.headers.has('location')).toBe(false); expect(response.headers.get('cache-control')).toBe('no-store'); - expect(visit).toMatchObject({ path: '/', outcome: 'invalid' }); + expect(visit).toMatchObject({ path: '/', outcome: 'valid' }); }); test('a direct link loop returns an invalid 404 without trying fallback', () => { diff --git a/apps/web/src/lib/server/redirect.ts b/apps/web/src/lib/server/redirect.ts index 2a4392f..015984e 100644 --- a/apps/web/src/lib/server/redirect.ts +++ b/apps/web/src/lib/server/redirect.ts @@ -91,13 +91,12 @@ function resolveVisitResponse( target: ParsedTarget, now: number ): ResolvedResponse { + const isRoot = target.path === '/'; let redirect = domain.fallback; let outcome: VisitOutcome = 'invalid'; - if (target.path === '/') { - if (domain.root !== null) { - redirect = domain.root; - outcome = 'valid'; - } + if (isRoot) { + outcome = 'valid'; + if (domain.root !== null) redirect = domain.root; } else { const link = findActiveLink(domain, target.path, now); if (link) { @@ -108,7 +107,7 @@ function resolveVisitResponse( if (redirect === null) { const response = emptyResponse(404); - return { response, outcome: 'invalid' }; + return { response, outcome }; } let destination = redirect.url; @@ -116,7 +115,8 @@ function resolveVisitResponse( const selfRedirect = isSelfRedirect(destination, host, target.path); if (selfRedirect) { const response = emptyResponse(404); - return { response, outcome: 'invalid' }; + if (!isRoot) outcome = 'invalid'; + return { response, outcome }; } const response = redirectResponse(destination, redirect.status_code); diff --git a/apps/web/src/lib/server/visit-reports.test.ts b/apps/web/src/lib/server/visit-reports.test.ts index cc522c7..7db4b2c 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: 2, + version: 3, scope: summary.scope, period, daily: days.map((day, index) => ({ 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 7cc9abd..adf44ef 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,11 +1,6 @@ import { error } from '@sveltejs/kit'; import { countBrowserOsVisitsByTimeframe } from '@goto/analytics'; -import { - availableVisitTimeframes, - countVisitWindows, - visitWindow, - type VisitTimeframe -} from '@goto/analytics/daily'; +import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily'; import { currentLinkState } from '#lib/server/link-state.js'; import { readDomainDetails } from '#lib/server/visit-reports.js'; import type { PageServerLoad } from './$types'; @@ -36,35 +31,32 @@ export const load = (async ({ locals, params }) => { domainDetails ?? undefined, windows ); - const baseCountsByTimeframe = countVisitWindows(detailsByPath.get('/')?.daily, windows); - const invalidClicksByTimeframe: Partial> = {}; - for (const { timeframe } of windows) { - const totals = countsByTimeframe[timeframe]; - if (!totals) continue; - const base = baseCountsByTimeframe[timeframe]; - if (!base) continue; - invalidClicksByTimeframe[timeframe] = totals.invalidVisits - base.invalidVisits; - } - + let rootUrl: string | null = null; + if (prepared.root !== null) rootUrl = prepared.root.url; + else if (prepared.fallback !== null) rootUrl = prepared.fallback.url; + const rootCountsByTimeframe = countVisitWindows(detailsByPath.get('/')?.daily, windows); + const rootHref = linkHref(domain, '/'); const now = Date.now(); - const links = Object.entries(prepared.links).map(([path, link]) => { - const daily = detailsByPath.get(path)?.daily; - const countsByTimeframe = countVisitWindows(daily, windows); - return { - path, - url: link.url, - state: currentLinkState(link, now), - countsByTimeframe, - href: linkHref(domain, path) - }; - }); - const hasRoot = prepared.root !== null; - let baseUrl: string | null = null; - if (prepared.root !== null) { - baseUrl = prepared.root.url; - } else if (prepared.fallback !== null) { - baseUrl = prepared.fallback.url; - } + const links = [ + { + path: '/', + url: rootUrl, + state: 'active' as const, + countsByTimeframe: rootCountsByTimeframe, + href: rootHref + }, + ...Object.entries(prepared.links).map(([path, link]) => { + const daily = detailsByPath.get(path)?.daily; + const countsByTimeframe = countVisitWindows(daily, windows); + return { + path, + url: link.url, + state: currentLinkState(link, now), + countsByTimeframe, + href: linkHref(domain, path) + }; + }) + ]; const asOf = domainDetails ? Date.parse(domainDetails.period.asOf) : null; const chart = domainDetails ? { daily: domainDetails.daily, period: domainDetails.period } : null; return { @@ -73,18 +65,13 @@ export const load = (async ({ locals, params }) => { collectionEnabled, available: domainDetails !== null, asOf, - linkCount: links.length, + linkCount: currentPaths.length, timeframes, countsByTimeframe, browserOsVisitsByTimeframe, - invalidClicksByTimeframe, chart, - base: { - url: baseUrl, - hasRoot, - countsByTimeframe: baseCountsByTimeframe, - href: linkHref(domain, '/') - }, + root: prepared.root, + fallback: prepared.fallback, links }; }) satisfies PageServerLoad; 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 ffaa173..746d765 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 @@ -20,8 +20,7 @@ data.chart ? visitGranularity(timeframe, data.chart.period.retentionMonths) : 'day' ); 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 invalidClicks = $derived(data.countsByTimeframe[timeframe]?.invalidVisits ?? null); let browserOsVisits = $derived(data.browserOsVisitsByTimeframe[timeframe] ?? null); @@ -47,13 +46,6 @@ {#if data.collectionEnabled} - - - {/if}
@@ -61,14 +53,17 @@
Domain
{data.domain}
-
Base destination
+
Root
- {#if data.base.url}{data.base.url} - {:else}404 Not found{/if} + {#if data.root} + {data.root.url} + {:else}Uses fallback{/if}
-
Base status
+
Fallback
- {#if data.base.hasRoot}Active{:else}Fallback{/if} + {#if data.fallback} + {data.fallback.url} + {:else}404 Not found{/if}
@@ -80,22 +75,11 @@ {/if}

Links

- {#if data.links.length === 0}

No links configured.

- {:else} - - {/if} +
- - diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/+page.server.ts b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/+page.server.ts new file mode 100644 index 0000000..44c8174 --- /dev/null +++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/+page.server.ts @@ -0,0 +1,43 @@ +import { error } from '@sveltejs/kit'; +import { countBrowserOsVisitsByTimeframe } from '@goto/analytics'; +import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily'; +import { readVisitDetails } from '#lib/server/visit-reports.js'; +import type { PageServerLoad } from './$types'; + +export const load = (async ({ locals, params }) => { + const snapshot = locals.snapshot; + const domain = params.domain; + if (!Object.hasOwn(snapshot.domains, domain)) throw error(404, 'Domain not configured'); + const prepared = snapshot.domains[domain]!; + const response = prepared.root; + const fallback = prepared.fallback; + const configuration = snapshot.configuration; + const retentionMonths = configuration.analytics.retention_months; + const collectionEnabled = retentionMonths !== 0; + const scope = { kind: 'link' as const, domain, path: '/' }; + const details = collectionEnabled ? await readVisitDetails(configuration, scope) : null; + const timeframes = retentionMonths === 0 ? [] : availableVisitTimeframes(retentionMonths); + const windows = details + ? timeframes.map((timeframe) => ({ timeframe, window: visitWindow(details.period, timeframe) })) + : []; + const countsByTimeframe = countVisitWindows(details?.daily, windows); + const browserOsVisitsByTimeframe = countBrowserOsVisitsByTimeframe(details ?? undefined, windows); + const asOf = details ? Date.parse(details.period.asOf) : null; + const chart = details ? { daily: details.daily, period: details.period } : null; + + return { + domain, + domainHref: `/admin/domains/${encodeURIComponent(domain)}`, + response, + fallback, + fallbackSource: prepared.provenance.fallback, + retentionMonths, + collectionEnabled, + available: details !== null, + asOf, + timeframes, + countsByTimeframe, + browserOsVisitsByTimeframe, + chart + }; +}) satisfies PageServerLoad; diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/+page.svelte b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/+page.svelte new file mode 100644 index 0000000..4fb976b --- /dev/null +++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/+page.svelte @@ -0,0 +1,103 @@ + + + + {#snippet actions()} + {#if data.available && data.timeframes.length > 0} + + {/if} + {/snippet} + +
+ {#if data.collectionEnabled} + + {/if} +
+
+

Root details

+
+
Domain
+
{data.domain}
+
Address
+
{data.domain}/
+
Destination
+
+ {#if data.response} + + {data.response.url} + + {:else} + Uses fallback + {/if} +
+ {#if data.response} +
HTTP status
+
{data.response.status_code}
+
Forward query
+
{data.response.forward_query ? 'Yes' : 'No'}
+ {/if} +
+
+
+

Fallback

+
+
Source
+
+ {#if data.fallbackSource === 'domain'}Domain + {:else if data.fallbackSource === 'global'}Global + {:else}Default{/if} +
+
Destination
+
+ {#if data.fallback} + + {data.fallback.url} + + {:else}404 Not found{/if} +
+ {#if data.fallback} +
HTTP status
+
{data.fallback.status_code}
+
Forward query
+
{data.fallback.forward_query ? 'Yes' : 'No'}
+ {/if} +
+
+ {#if data.chart && window} + + {/if} + {#if browserOsVisits && visits !== null} + + {/if} +
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 4ca0768..12e0e04 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,7 +1,7 @@ import { error } from '@sveltejs/kit'; import { countBrowserOsVisitsByTimeframe } from '@goto/analytics'; import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily'; -import { currentLinkState, type LinkState } from '#lib/server/link-state.js'; +import { currentLinkState } from '#lib/server/link-state.js'; import { readVisitDetails } from '#lib/server/visit-reports.js'; import type { PageServerLoad } from './$types'; @@ -12,26 +12,16 @@ export const load = (async ({ locals, params }) => { const prepared = snapshot.domains[domain]!; // Kit has decoded route parameters; preserve literal percent paths as-is. const path = `/${params.path ?? ''}`; - if (path !== '/' && !Object.hasOwn(prepared.links, path)) throw error(404, 'Link not configured'); - let url: string | null = null; - let state: LinkState | 'fallback'; - if (path === '/') { - const root = prepared.root; - if (root !== null) { - url = root.url; - state = 'active'; - } else { - state = 'fallback'; - } - } else { - const link = prepared.links[path]!; - url = link.url; - state = currentLinkState(link, Date.now()); - } - let fallback: string | 404 = 404; - if (prepared.fallback !== null) { - fallback = prepared.fallback.url; - } + if (!Object.hasOwn(prepared.links, path)) throw error(404, 'Link not configured'); + const link = prepared.links[path]!; + const response = { + url: link.url, + status_code: link.status_code, + forward_query: link.forward_query + }; + const state = currentLinkState(link, Date.now()); + const validFrom = link.valid_from ?? null; + const validUntil = link.valid_until ?? null; const configuration = snapshot.configuration; const retentionMonths = configuration.analytics.retention_months; const collectionEnabled = retentionMonths !== 0; @@ -50,9 +40,12 @@ export const load = (async ({ locals, params }) => { domain, path, domainHref: `/admin/domains/${encodeURIComponent(domain)}`, - url, - fallback, + response, state, + validFrom, + validUntil, + tags: link.tags, + sources: link.provenance, retentionMonths, collectionEnabled, available: details !== null, 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 ff4b504..7c8dc21 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 @@ -1,12 +1,14 @@ +{#snippet sourceHint(source: SettingSource)} + + {#if source === 'builtin'}Built-in + {:else if source === 'global'}Global defaults + {:else if source === 'domain'}Domain defaults + {:else}Link override{/if} + +{/snippet} + {:else if data.state === 'expired'} - {:else} - {/if}
@@ -64,9 +73,36 @@
{data.path}
Destination
- {#if data.url}{data.url} - {:else if data.fallback === 404}404 Not found{:else}{data.fallback}{/if} + + {data.response.url} +
+
HTTP status
+
+ {data.response.status_code} + {@render sourceHint(data.sources.status_code)} +
+
Forward query
+
+ {data.response.forward_query ? 'Yes' : 'No'} + {@render sourceHint(data.sources.forward_query)} +
+ {#if data.validFrom} +
Starts
+
+ +
+ {/if} + {#if data.validUntil} +
Expires
+
+ +
+ {/if} + {#if data.tags.length > 0} +
Tags
+
{data.tags.join(', ')}
+ {/if}
{#if data.chart && window} @@ -76,3 +112,11 @@ {/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 173d4b7..292a149 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 @@ -1,4 +1,4 @@ -import { validateConfig } from '@goto/config'; +import { validateConfig, type DomainConfiguration } from '@goto/config'; import { createVisitPeriod, visitDays, type VisitDetails } from '@goto/analytics'; import { beforeEach, expect, test, vi } from 'vitest'; import { prepareSnapshot } from '#lib/server/snapshot.js'; @@ -9,27 +9,43 @@ vi.mock('#lib/server/visit-reports.js', () => ({ readVisitDetails: vi.fn() })); beforeEach(() => vi.resetAllMocks()); const domain = 'go.example.test'; -function request(path = '/docs', host = domain, retention_months = 3, rootUrl?: string) { +const linkMetadata = { + response: { url: 'https://example.test/docs', status_code: 308, forward_query: true }, + state: 'active', + validFrom: '2000-01-01T00:00:00Z', + validUntil: '2100-01-01T00:00:00Z', + tags: ['guides', 'reference'], + sources: { status_code: 'link', forward_query: 'domain' } +}; + +function request(path = '/docs', options: { host?: string; retentionMonths?: number } = {}) { + const domainConfiguration: Partial = { + link_defaults: { forward_query: true }, + links: { + '/docs': { + url: 'https://example.test/docs', + status_code: 308, + enabled: true, + valid_from: '2000-01-01T00:00:00Z', + valid_until: '2100-01-01T00:00:00Z', + tags: ['guides', 'reference'] + }, + '/a/b': { url: 'https://example.test/nested', enabled: true, tags: [] }, + '/a%2Fb': { url: 'https://example.test/percent', enabled: false, tags: [] } + } + }; const prepared = validateConfig( { auth: { credentials: { username: 'test', password: 'link-test-password' } }, - analytics: { retention_months }, - domains: { - [domain]: { - ...(rootUrl ? { root: { url: rootUrl } } : {}), - links: { - '/docs': { url: 'https://example.test/docs' }, - '/a/b': { url: 'https://example.test/nested' }, - '/a%2Fb': { url: 'https://example.test/percent', enabled: false } - } - } - } + analytics: { retention_months: options.retentionMonths ?? 3 }, + link_defaults: { status_code: 301 }, + domains: { [domain]: domainConfiguration } }, { env: {} } ); return { locals: { snapshot: prepareSnapshot(prepared) }, - params: { domain: host, path: path.slice(1) } + params: { domain: options.host ?? domain, path: path.slice(1) } } as Parameters[0]; } @@ -48,7 +64,7 @@ const browserOsCounts = Object.entries(counts).map(([day, [validVisits, invalidV invalidVisits })); const details: VisitDetails = { - version: 2, + version: 3, scope: { kind: 'link', domain, path: '/docs' }, period, daily: visitDays(period).map((day) => { @@ -59,7 +75,7 @@ const details: VisitDetails = { operatingSystems: browserOsCounts }; -test('projects only the requested link series and compact window counts and breakdowns', async () => { +test('projects only requested link settings, series and compact window counts and breakdowns', async () => { vi.mocked(readVisitDetails).mockResolvedValue(details); const data = await load(request()); expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), { @@ -71,9 +87,7 @@ test('projects only the requested link series and compact window counts and brea domain, path: '/docs', domainHref: '/admin/domains/go.example.test', - url: 'https://example.test/docs', - fallback: 404, - state: 'active', + ...linkMetadata, retentionMonths: 3, collectionEnabled: true, available: true, @@ -102,15 +116,21 @@ test('projects only the requested link series and compact window counts and brea }); }); -test('keeps decoded nested and literal-percent identities distinct and supports historical root traffic', async () => { +test('keeps decoded nested and literal-percent identities distinct', async () => { vi.mocked(readVisitDetails) .mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/a/b' } }) - .mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/a%2Fb' } }) - .mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/' } }); + .mockResolvedValueOnce({ ...details, scope: { kind: 'link', domain, path: '/a%2Fb' } }); const nested = await load(request('/a/b')); const percent = await load(request('/a%2Fb')); - const root = await load(request('/')); expect(nested.path).toBe('/a/b'); + expect(nested).toMatchObject({ + response: { url: 'https://example.test/nested', status_code: 301, forward_query: true }, + state: 'active', + validFrom: null, + validUntil: null, + tags: [], + sources: { status_code: 'global', forward_query: 'domain' } + }); expect(percent.path).toBe('/a%2Fb'); expect(percent.state).toBe('disabled'); expect(percent.countsByTimeframe.all?.validVisits).toBe(15); @@ -124,38 +144,14 @@ test('keeps decoded nested and literal-percent identities distinct and supports domain, path: '/a%2Fb' }); - expect(readVisitDetails).toHaveBeenNthCalledWith(3, expect.any(Object), { - kind: 'link', - domain, - path: '/' - }); - expect(root.path).toBe('/'); - expect(root.state).toBe('fallback'); - expect(root.url).toBeNull(); - expect(root.fallback).toBe(404); - expect(root.countsByTimeframe.all?.validVisits).toBe(15); -}); - -test('shows the configured root response while preserving its historical analytics scope', async () => { - vi.mocked(readVisitDetails).mockResolvedValue({ - ...details, - scope: { kind: 'link', domain, path: '/' } - }); - const data = await load(request('/', domain, 3, 'https://example.test/start')); - expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), { - kind: 'link', - domain, - path: '/' - }); - expect(data.path).toBe('/'); - expect(data.state).toBe('active'); - expect(data.url).toBe('https://example.test/start'); - expect(data.countsByTimeframe.all).toEqual({ validVisits: 15, invalidVisits: 5 }); }); test('rejects unknown scopes before reading and keeps unavailable counts absent', async () => { - await expect(load(request('/docs', 'unknown.test'))).rejects.toMatchObject({ status: 404 }); + await expect(load(request('/docs', { host: 'unknown.test' }))).rejects.toMatchObject({ + status: 404 + }); await expect(load(request('/not-configured'))).rejects.toMatchObject({ status: 404 }); + await expect(load(request('/'))).rejects.toMatchObject({ status: 404 }); expect(readVisitDetails).not.toHaveBeenCalled(); vi.mocked(readVisitDetails).mockResolvedValue(null); const data = await load(request()); @@ -164,12 +160,12 @@ test('rejects unknown scopes before reading and keeps unavailable counts absent' expect(data.chart).toBeNull(); expect(data.asOf).toBeNull(); expect(data.countsByTimeframe).toEqual({}); - expect(data.url).toBe('https://example.test/docs'); + expect(data).toMatchObject(linkMetadata); expect(data.retentionMonths).toBe(3); }); test('disabled analytics skips the report read and leaves link details usable', async () => { - const data = await load(request('/docs', domain, 0)); + const data = await load(request('/docs', { retentionMonths: 0 })); expect(readVisitDetails).not.toHaveBeenCalled(); expect(data.collectionEnabled).toBe(false); expect(data.browserOsVisitsByTimeframe).toEqual({}); @@ -178,6 +174,5 @@ test('disabled analytics skips the report read and leaves link details usable', expect(data.timeframes).toEqual([]); expect(data.chart).toBeNull(); expect(data.countsByTimeframe).toEqual({}); - expect(data.url).toBe('https://example.test/docs'); - expect(data.state).toBe('active'); + expect(data).toMatchObject(linkMetadata); }); diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/page.server.test.ts b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/page.server.test.ts new file mode 100644 index 0000000..095247e --- /dev/null +++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/page.server.test.ts @@ -0,0 +1,137 @@ +import { validateConfig, type DomainConfiguration, type RedirectResponse } from '@goto/config'; +import { createVisitPeriod, visitDays, type VisitDetails } from '@goto/analytics'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { prepareSnapshot } from '#lib/server/snapshot.js'; +import { readVisitDetails } from '#lib/server/visit-reports.js'; +import { load } from './+page.server.js'; + +vi.mock('#lib/server/visit-reports.js', () => ({ readVisitDetails: vi.fn() })); +beforeEach(() => vi.resetAllMocks()); + +const domain = 'go.example.test'; +const fallback: RedirectResponse = { + url: 'https://example.test/fallback', + status_code: 307, + forward_query: true +}; + +function request(options: { host?: string; root?: RedirectResponse } = {}) { + const domainConfiguration: Partial = { + fallback, + link_defaults: { forward_query: true }, + links: {} + }; + if (options.root) domainConfiguration.root = options.root; + const prepared = validateConfig( + { + auth: { credentials: { username: 'test', password: 'link-test-password' } }, + analytics: { retention_months: 3 }, + link_defaults: { status_code: 301 }, + domains: { [domain]: domainConfiguration } + }, + { env: {} } + ); + return { + locals: { snapshot: prepareSnapshot(prepared) }, + params: { domain: options.host ?? domain } + } as Parameters[0]; +} + +const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z')); +const rootBrowserOsCounts = [ + { day: period.from.slice(0, 10), name: 'Other', validVisits: 7, invalidVisits: 0 } +]; +const rootDetails: VisitDetails = { + version: 3, + period, + scope: { kind: 'link', domain, path: '/' }, + daily: visitDays(period).map((day, index) => ({ + ...day, + validVisits: index === 0 ? 7 : 0, + invalidVisits: 0 + })), + browsers: rootBrowserOsCounts, + operatingSystems: rootBrowserOsCounts +}; + +test('supports historical root traffic without a configured root', async () => { + vi.mocked(readVisitDetails).mockResolvedValue(rootDetails); + const data = await load(request()); + expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), { + kind: 'link', + domain, + path: '/' + }); + expect(data).toMatchObject({ + domain, + domainHref: '/admin/domains/go.example.test', + response: null, + fallback, + fallbackSource: 'domain' + }); + expect(data.countsByTimeframe.all?.validVisits).toBe(7); +}); + +test('keeps root HTTP settings independent of link defaults and fallback with historical analytics scope', async () => { + vi.mocked(readVisitDetails).mockResolvedValue(rootDetails); + const response: RedirectResponse = { + url: 'https://example.test/start', + status_code: 302, + forward_query: false + }; + const data = await load(request({ root: response })); + expect(readVisitDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), { + kind: 'link', + domain, + path: '/' + }); + expect(data).toMatchObject({ + response, + fallback, + fallbackSource: 'domain' + }); + expect(data.countsByTimeframe.all).toEqual({ validVisits: 7, invalidVisits: 0 }); +}); + +test('keeps absent root and default or global fallback metadata usable without analytics', async () => { + const prepared = validateConfig( + { + auth: { credentials: { username: 'test', password: 'link-test-password' } }, + analytics: { retention_months: 0 }, + domains: { [domain]: { links: {} } } + }, + { env: {} } + ); + const event = { + locals: { snapshot: prepareSnapshot(prepared) }, + params: { domain } + } as Parameters[0]; + const root = await load(event); + expect(root).toMatchObject({ + response: null, + fallback: null, + fallbackSource: 'builtin' + }); + const global = validateConfig( + { + auth: { credentials: { username: 'test', password: 'link-test-password' } }, + analytics: { retention_months: 0 }, + fallback, + domains: { [domain]: { links: {} } } + }, + { env: {} } + ); + event.locals.snapshot = prepareSnapshot(global); + const inherited = await load(event); + expect(inherited).toMatchObject({ + response: null, + fallback, + fallbackSource: 'global' + }); + expect(readVisitDetails).not.toHaveBeenCalled(); +}); + +test('rejects unknown domains before reading root traffic', async () => { + await expect(load(request({ host: 'unknown.test' }))).rejects.toMatchObject({ status: 404 }); + expect(readVisitDetails).not.toHaveBeenCalled(); +}); 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 63c31e3..b35d6c4 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 @@ -1,4 +1,4 @@ -import { validateConfig } from '@goto/config'; +import { validateConfig, type DomainConfiguration, type RedirectResponse } from '@goto/config'; import { createVisitPeriod, visitDays, type ReportScope, type VisitDetails } from '@goto/analytics'; import { beforeEach, expect, test, vi } from 'vitest'; import { prepareSnapshot } from '#lib/server/snapshot.js'; @@ -11,20 +11,33 @@ beforeEach(() => vi.resetAllMocks()); const domain = 'go.example.test'; const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z')); -function request(host = domain, rootUrl?: string, retentionMonths = 3) { - const links: Record = { +function request( + host = domain, + settings: { + root?: RedirectResponse; + fallback?: RedirectResponse; + globalFallback?: RedirectResponse; + retentionMonths?: number; + links?: Record; + } = {} +) { + const links = settings.links ?? { '/docs': { url: 'https://example.test/docs' }, '/a/b': { url: 'https://example.test/nested' }, '/a%2Fb': { url: 'https://example.test/percent', enabled: false } }; - const prepared = validateConfig( - { - auth: { credentials: { username: 'test', password: 'domain-test-password' } }, - analytics: { retention_months: retentionMonths }, - domains: { [domain]: { ...(rootUrl ? { root: { url: rootUrl } } : {}), links } } - }, - { env: {} } - ); + const domainConfiguration: Pick & { + links: typeof links; + } = { links }; + if (settings.root) domainConfiguration.root = settings.root; + if (settings.fallback) domainConfiguration.fallback = settings.fallback; + const configuration: Record = { + auth: { credentials: { username: 'test', password: 'domain-test-password' } }, + analytics: { retention_months: settings.retentionMonths ?? 3 }, + domains: { [domain]: domainConfiguration } + }; + if (settings.globalFallback) configuration.fallback = settings.globalFallback; + const prepared = validateConfig(configuration, { env: {} }); return { locals: { snapshot: prepareSnapshot(prepared) }, params: { domain: host } @@ -45,7 +58,7 @@ function details(scope: ReportScope, counts: Record): invalidVisits })); return { - version: 2, + version: 3, scope, period, daily, @@ -57,20 +70,20 @@ function details(scope: ReportScope, counts: Record): const domainDetails = details( { kind: 'domain', domain }, { - '2026-03-15': [3, 2], + '2026-03-15': [4, 1], '2026-05-16': [5, 4], - '2026-06-08': [2, 3], - '2026-06-09': [1, 2], - '2026-06-15': [4, 3] + '2026-06-08': [3, 2], + '2026-06-09': [2, 1], + '2026-06-15': [5, 2] } ); -const baseDetails = details( +const rootDetails = details( { kind: 'link', domain, path: '/' }, { - '2026-03-15': [3, 1], - '2026-06-08': [0, 1], - '2026-06-09': [0, 1], - '2026-06-15': [0, 1] + '2026-03-15': [4, 0], + '2026-06-08': [1, 0], + '2026-06-09': [1, 0], + '2026-06-15': [1, 0] } ); const docsDetails = details( @@ -85,15 +98,15 @@ const emptyDetails = details({ kind: 'link', domain, path: '/a%2Fb' }, {}); const report = { domain: domainDetails, links: [ - { path: '/', details: baseDetails }, + { path: '/', details: rootDetails }, { path: '/docs', details: docsDetails }, { path: '/a%2Fb', details: emptyDetails } ] }; -const baseCounts = { - '7d': { validVisits: 0, invalidVisits: 2 }, - '30d': { validVisits: 0, invalidVisits: 3 }, - all: { validVisits: 3, invalidVisits: 4 } +const rootCounts = { + '7d': { validVisits: 2, invalidVisits: 0 }, + '30d': { validVisits: 3, invalidVisits: 0 }, + all: { validVisits: 7, invalidVisits: 0 } }; test('projects domain counts and browser/OS visits without exposing link series or dated browser/OS counts', async () => { @@ -113,33 +126,35 @@ test('projects domain counts and browser/OS visits without exposing link series linkCount: 3, timeframes: ['7d', '30d', 'all'], countsByTimeframe: { - '7d': { validVisits: 5, invalidVisits: 5 }, - '30d': { validVisits: 7, invalidVisits: 8 }, - all: { validVisits: 15, invalidVisits: 14 } + '7d': { validVisits: 7, invalidVisits: 3 }, + '30d': { validVisits: 10, invalidVisits: 5 }, + all: { validVisits: 19, invalidVisits: 10 } }, browserOsVisitsByTimeframe: { '7d': { - browsers: [{ name: 'Other', visits: 5 }], - operatingSystems: [{ name: 'Other', visits: 5 }] - }, - '30d': { browsers: [{ name: 'Other', visits: 7 }], operatingSystems: [{ name: 'Other', visits: 7 }] }, + '30d': { + browsers: [{ name: 'Other', visits: 10 }], + operatingSystems: [{ name: 'Other', visits: 10 }] + }, all: { - browsers: [{ name: 'Other', visits: 15 }], - operatingSystems: [{ name: 'Other', visits: 15 }] + browsers: [{ name: 'Other', visits: 19 }], + operatingSystems: [{ name: 'Other', visits: 19 }] } }, - invalidClicksByTimeframe: { '7d': 3, '30d': 5, all: 10 }, chart: { daily: domainDetails.daily, period }, - base: { - url: null, - hasRoot: false, - countsByTimeframe: baseCounts, - href: '/admin/domains/go.example.test/links' - }, + root: null, + fallback: null, links: [ + { + path: '/', + url: null, + state: 'active', + countsByTimeframe: rootCounts, + href: '/admin/domains/go.example.test/links' + }, { path: '/docs', url: 'https://example.test/docs', @@ -173,53 +188,132 @@ test('projects domain counts and browser/OS visits without exposing link series }); }); -test('shows an active root destination without reclassifying retained outcomes or subtracting valid root traffic', async () => { +test('keeps root and fallback responses separate and includes all normalized root history in valid domain visits', async () => { vi.mocked(readDomainDetails).mockResolvedValue(report); - const data = await load(request(domain, 'https://example.test/start')); - expect(data.base.url).toBe('https://example.test/start'); - expect(data.base.hasRoot).toBe(true); - expect(data.base.countsByTimeframe).toEqual(baseCounts); - expect(data.countsByTimeframe.all?.validVisits).toBe(15); - expect(data.invalidClicksByTimeframe.all).toBe(10); + const root: RedirectResponse = { + url: 'https://example.test/start', + status_code: 307, + forward_query: true + }; + const fallback: RedirectResponse = { + url: 'https://example.test/missing', + status_code: 308, + forward_query: false + }; + const data = await load( + request(domain, { + root, + fallback, + globalFallback: { url: 'https://example.test/global', status_code: 302, forward_query: true } + }) + ); + expect(data.root).toEqual(root); + expect(data.fallback).toEqual(fallback); + expect(data.links[0]).toEqual({ + path: '/', + url: root.url, + state: 'active', + countsByTimeframe: rootCounts, + href: '/admin/domains/go.example.test/links' + }); + expect(data.countsByTimeframe.all).toEqual({ validVisits: 19, invalidVisits: 10 }); expect(data.linkCount).toBe(3); - expect(data.links.map(({ path }) => path)).toEqual(['/docs', '/a/b', '/a%2Fb']); + expect(data.links.map(({ path }) => path)).toEqual(['/', '/docs', '/a/b', '/a%2Fb']); }); -test('missing base details leave base and non-base invalid counts unavailable, not zero', async () => { +test('keeps root reachable through the domain fallback with no named links', async () => { + vi.mocked(readDomainDetails).mockResolvedValue(report); + const fallback: RedirectResponse = { + url: 'https://example.test/missing', + status_code: 302, + forward_query: false + }; + const data = await load(request(domain, { fallback, links: {} })); + expect(readDomainDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), domain, []); + expect(data.root).toBeNull(); + expect(data.fallback).toEqual(fallback); + expect(data.linkCount).toBe(0); + expect(data.links).toEqual([ + { + path: '/', + url: fallback.url, + state: 'active', + countsByTimeframe: rootCounts, + href: '/admin/domains/go.example.test/links' + } + ]); + expect(data.countsByTimeframe.all).toEqual({ validVisits: 19, invalidVisits: 10 }); +}); + +test('missing root details leave root visits unavailable without hiding non-root invalid counts', async () => { vi.mocked(readDomainDetails).mockResolvedValue({ domain: domainDetails, links: [{ path: '/docs', details: docsDetails }] }); const data = await load(request()); expect(data.available).toBe(true); - expect(data.base.countsByTimeframe).toEqual({}); - expect(data.invalidClicksByTimeframe).toEqual({}); - expect(data.countsByTimeframe.all?.validVisits).toBe(15); + expect(data.links[0]).toEqual({ + path: '/', + url: null, + state: 'active', + countsByTimeframe: {}, + href: '/admin/domains/go.example.test/links' + }); + expect(data.countsByTimeframe.all).toEqual({ validVisits: 19, invalidVisits: 10 }); }); test('rejects unknown domains before reading and keeps unavailable inventory usable', async () => { await expect(load(request('unknown.test'))).rejects.toMatchObject({ status: 404 }); expect(readDomainDetails).not.toHaveBeenCalled(); vi.mocked(readDomainDetails).mockResolvedValue(null); - const data = await load(request()); + const fallback: RedirectResponse = { + url: 'https://example.test/global', + status_code: 307, + forward_query: true + }; + const data = await load(request(domain, { globalFallback: fallback })); + expect(data.root).toBeNull(); + expect(data.fallback).toEqual(fallback); expect(data.available).toBe(false); expect(data.browserOsVisitsByTimeframe).toEqual({}); expect(data.chart).toBeNull(); expect(data.countsByTimeframe).toEqual({}); - expect(data.invalidClicksByTimeframe).toEqual({}); - expect(data.links[0]?.countsByTimeframe).toEqual({}); + expect(data.links[0]).toEqual({ + path: '/', + url: fallback.url, + state: 'active', + countsByTimeframe: {}, + href: '/admin/domains/go.example.test/links' + }); expect(data.linkCount).toBe(3); }); test('disabled collection reads no reports and preserves current inventory', async () => { - const data = await load(request(domain, undefined, 0)); + const root: RedirectResponse = { + url: 'https://example.test/start', + status_code: 301, + forward_query: true + }; + const fallback: RedirectResponse = { + url: 'https://example.test/missing', + status_code: 302, + forward_query: false + }; + const data = await load(request(domain, { root, fallback, retentionMonths: 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({}); - expect(data.invalidClicksByTimeframe).toEqual({}); - expect(data.base.hasRoot).toBe(false); + expect(data.root).toEqual(root); + expect(data.fallback).toEqual(fallback); + expect(data.links[0]).toEqual({ + path: '/', + url: root.url, + state: 'active', + countsByTimeframe: {}, + href: '/admin/domains/go.example.test/links' + }); expect(data.linkCount).toBe(3); }); diff --git a/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte index 27670f1..ea2c174 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte +++ b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte @@ -4,21 +4,11 @@ import IconWarningCircle from 'virtual:icons/ph/warning-circle'; import IconSpinner from 'virtual:icons/ph/spinner'; import AdminPage from '#lib/components/admin/AdminPage.svelte'; + import { formatUtc } from '#lib/components/admin/format.js'; import type { ActionData, PageData, SubmitFunction } from './$types'; let { data, form }: { data: PageData; form: ActionData | null } = $props(); let pending = $state(false); - const utcFormatter = new Intl.DateTimeFormat('en', { - dateStyle: 'medium', - timeStyle: 'medium', - hourCycle: 'h23', - timeZone: 'UTC' - }); - - function formatUtc(value: string): string { - return `${utcFormatter.format(new Date(value))} UTC`; - } - const checkWorker: SubmitFunction = () => { pending = true; return async ({ update }) => { @@ -41,11 +31,16 @@
{data.appUrl}
Authentication
{data.authMode === 'oidc' ? 'OIDC' : 'Shared credentials'}
-
Redirect status code
+ + +
+

Application defaults

+
+
Status code
{data.redirectStatusCode}
-
Forward query
+
Query forwarding
{data.forwardQuery ? 'Yes' : 'No'}
-
Fallback destination
+
Fallback
{data.fallbackDestination === 404 ? '404 Not found' : data.fallbackDestination}
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts b/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts index 9d8fa71..35e34bc 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts +++ b/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts @@ -42,7 +42,7 @@ function request(overrides: Record = {}) { function emptyOverview(): VisitDetails { const period = createVisitPeriod(3, Date.parse('2026-06-15T12:34:56.000Z')); return { - version: 2, + version: 3, scope: { kind: 'overview' }, period, daily: visitDays(period).map((day) => ({ ...day, validVisits: 0, invalidVisits: 0 })), 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 40703b9..0b2b30d 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 @@ -58,7 +58,7 @@ function details( invalidVisits })); return { - version: 2, + version: 3, scope, period, daily, diff --git a/apps/worker/src/services/build-visit-reports.test.ts b/apps/worker/src/services/build-visit-reports.test.ts index 3b2f1f9..daa6f27 100644 --- a/apps/worker/src/services/build-visit-reports.test.ts +++ b/apps/worker/src/services/build-visit-reports.test.ts @@ -22,10 +22,10 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges', domain: null, path: null, day: '2026-05-31', - validVisits: 1, - invalidVisits: 4, - baseValidVisits: 0, - baseInvalidVisits: 2 + validVisits: 3, + invalidVisits: 2, + baseValidVisits: 2, + baseInvalidVisits: 0 } ], browsers: [ @@ -44,8 +44,8 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges', path: null, day: '2026-05-31', name: 'Firefox', - validVisits: 1, - invalidVisits: 4 + validVisits: 3, + invalidVisits: 2 } ], operatingSystems: [ @@ -64,8 +64,8 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges', path: null, day: '2026-05-31', name: 'Linux', - validVisits: 1, - invalidVisits: 4 + validVisits: 3, + invalidVisits: 2 } ] }, @@ -77,18 +77,18 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges', version: 1, scope: { kind: 'overview' }, period, - totals: { validVisits: 3, invalidVisits: 5 }, - base: { validVisits: 1, invalidVisits: 2 }, + totals: { validVisits: 5, invalidVisits: 3 }, + base: { validVisits: 3, invalidVisits: 0 }, domains: [] }); - expect(report?.details.version).toBe(2); + expect(report?.details.version).toBe(3); 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 } + { day: '2026-05-31', name: 'Firefox', validVisits: 3, invalidVisits: 2 } ]); 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 } + { day: '2026-05-31', name: 'Linux', validVisits: 3, invalidVisits: 2 } ]); expect(report?.details.daily).toHaveLength(visitDays(period).length); expect(report?.details.daily[0]).toEqual({ @@ -109,8 +109,8 @@ test('builds gap-free UTC daily details with zeros and partial calendar edges', start: '2026-05-31T00:00:00.000Z', end: '2026-06-01T00:00:00.000Z', partial: true, - validVisits: 1, - invalidVisits: 4 + validVisits: 3, + invalidVisits: 2 }); }); @@ -126,18 +126,18 @@ test('indexes explicit domain and link scopes, including empty links and root, w { ...overview, day: '2026-06-01', - validVisits: 8, - invalidVisits: 3, - baseValidVisits: 1, - baseInvalidVisits: 1 + validVisits: 9, + invalidVisits: 2, + baseValidVisits: 2, + baseInvalidVisits: 0 }, { ...domain, day: '2026-06-01', - validVisits: 4, - invalidVisits: 2, - baseValidVisits: 1, - baseInvalidVisits: 1 + validVisits: 5, + invalidVisits: 1, + baseValidVisits: 2, + baseInvalidVisits: 0 }, { ...link, @@ -150,27 +150,27 @@ test('indexes explicit domain and link scopes, including empty links and root, w { ...root, day: '2026-06-01', - validVisits: 1, - invalidVisits: 1, - baseValidVisits: 1, - baseInvalidVisits: 1 + validVisits: 2, + invalidVisits: 0, + baseValidVisits: 2, + baseInvalidVisits: 0 } ], browsers: [ - { ...overview, day: '2026-06-01', name: 'Other', validVisits: 8, invalidVisits: 3 }, - { ...domain, day: '2026-06-01', name: 'Firefox', validVisits: 4, invalidVisits: 2 }, + { ...overview, day: '2026-06-01', name: 'Other', validVisits: 9, invalidVisits: 2 }, + { ...domain, day: '2026-06-01', name: 'Firefox', validVisits: 5, invalidVisits: 1 }, { ...link, day: '2026-06-01', name: 'Chrome', validVisits: 2, invalidVisits: 0 }, - { ...root, day: '2026-06-01', name: 'Firefox', validVisits: 1, invalidVisits: 1 } + { ...root, day: '2026-06-01', name: 'Firefox', validVisits: 2, invalidVisits: 0 } ], operatingSystems: [ - { ...overview, day: '2026-06-01', name: 'Other', validVisits: 8, invalidVisits: 3 }, - { ...domain, day: '2026-06-01', name: 'Linux', validVisits: 4, invalidVisits: 2 }, + { ...overview, day: '2026-06-01', name: 'Other', validVisits: 9, invalidVisits: 2 }, + { ...domain, day: '2026-06-01', name: 'Linux', validVisits: 5, invalidVisits: 1 }, { ...link, day: '2026-06-01', name: 'Linux', validVisits: 2, invalidVisits: 0 }, - { ...root, day: '2026-06-01', name: 'Linux', validVisits: 1, invalidVisits: 1 } + { ...root, day: '2026-06-01', name: 'Linux', validVisits: 2, invalidVisits: 0 } ] }, [ - { domain: 'go.example.test', paths: ['/docs', '/empty', '/'] }, + { domain: 'go.example.test', paths: ['/docs', '/empty'] }, { domain: 'empty.example.test', paths: ['/docs'] } ], period @@ -192,22 +192,21 @@ test('indexes explicit domain and link scopes, including empty links and root, w version: 1, scope: { kind: 'domain', domain: 'go.example.test' }, period, - totals: { validVisits: 4, invalidVisits: 2 }, - base: { validVisits: 1, invalidVisits: 1 }, + totals: { validVisits: 5, invalidVisits: 1 }, + base: { validVisits: 2, invalidVisits: 0 }, links: [ { path: '/docs', totals: { validVisits: 2, invalidVisits: 0 } }, - { path: '/empty', totals: { validVisits: 0, invalidVisits: 0 } }, - { path: '/', totals: { validVisits: 1, invalidVisits: 1 } } + { path: '/empty', totals: { validVisits: 0, invalidVisits: 0 } } ] }); expect(overviewReport?.summary).toEqual({ version: 1, scope: { kind: 'overview' }, period, - totals: { validVisits: 8, invalidVisits: 3 }, - base: { validVisits: 1, invalidVisits: 1 }, + totals: { validVisits: 9, invalidVisits: 2 }, + base: { validVisits: 2, invalidVisits: 0 }, domains: [ - { domain: 'go.example.test', totals: { validVisits: 4, invalidVisits: 2 } }, + { domain: 'go.example.test', totals: { validVisits: 5, invalidVisits: 1 } }, { domain: 'empty.example.test', totals: { validVisits: 0, invalidVisits: 0 } } ] }); @@ -221,10 +220,21 @@ test('indexes explicit domain and link scopes, including empty links and root, w { day: '2026-06-01', name: 'Chrome', validVisits: 2, invalidVisits: 0 } ]); expect(domainReport?.details.browsers).toEqual([ - { day: '2026-06-01', name: 'Firefox', validVisits: 4, invalidVisits: 2 } + { day: '2026-06-01', name: 'Firefox', validVisits: 5, invalidVisits: 1 } + ]); + expect(base?.summary.totals).toEqual({ validVisits: 2, invalidVisits: 0 }); + expect(base?.details.daily.find(({ start }) => start === '2026-06-01T00:00:00.000Z')).toEqual({ + start: '2026-06-01T00:00:00.000Z', + end: '2026-06-02T00:00:00.000Z', + partial: false, + validVisits: 2, + invalidVisits: 0 + }); + expect(base?.details.browsers).toEqual([ + { day: '2026-06-01', name: 'Firefox', validVisits: 2, invalidVisits: 0 } ]); expect(base?.details.operatingSystems).toEqual([ - { day: '2026-06-01', name: 'Linux', validVisits: 1, invalidVisits: 1 } + { day: '2026-06-01', name: 'Linux', validVisits: 2, invalidVisits: 0 } ]); expect(empty?.summary.totals).toEqual({ validVisits: 0, invalidVisits: 0 }); expect(empty?.details.daily).toEqual( @@ -258,6 +268,7 @@ test('indexes explicit domain and link scopes, including empty links and root, w ); expect(unconfiguredRoot?.summary.totals).toEqual({ validVisits: 0, invalidVisits: 0 }); for (const report of reports) { + expect(report.details.version).toBe(3); expect(report.details.scope).toEqual(report.summary.scope); expect(report.details.period).toEqual(period); } diff --git a/apps/worker/src/services/build-visit-reports.ts b/apps/worker/src/services/build-visit-reports.ts index 4349826..1b0191c 100644 --- a/apps/worker/src/services/build-visit-reports.ts +++ b/apps/worker/src/services/build-visit-reports.ts @@ -153,7 +153,7 @@ function buildDetails( days: ReturnType ): VisitDetails { return { - version: 2, + version: 3, scope, period, daily: buildDailyDetails(counts.daily, days), diff --git a/apps/worker/src/services/prepare-visit-reports.test.ts b/apps/worker/src/services/prepare-visit-reports.test.ts index 1869fc2..34d6520 100644 --- a/apps/worker/src/services/prepare-visit-reports.test.ts +++ b/apps/worker/src/services/prepare-visit-reports.test.ts @@ -47,6 +47,7 @@ const visit: ClassifiedVisit = { const overview = { kind: 'overview' as const }; const domain = { kind: 'domain' as const, domain: 'go.example.test' }; const docs = { kind: 'link' as const, domain: 'go.example.test', path: '/docs' }; +const root = { ...docs, path: '/' }; function sum(values: VisitTotals[]): VisitTotals { return values.reduce( @@ -69,10 +70,16 @@ test('publishes summary/detail pairs for all scopes, deletes expired history and .execute(); await insertVisit(ctx.db, { ...visit, visited_at: cutoff }); await insertVisit(ctx.db, { ...visit, path: '/', outcome: 'invalid', visited_at: now - 2000 }); + await insertVisit(ctx.db, { + ...visit, + path: '/missing', + outcome: 'invalid', + visited_at: now - 1000 + }); await prepareVisitReports(ctx, now); const summary = await readVisitSummary(ctx.db, domain); const details = await readVisitDetails(ctx.db, domain); - expect(summary?.totals).toEqual({ validVisits: 1, invalidVisits: 1 }); + expect(summary?.totals).toEqual({ validVisits: 2, invalidVisits: 1 }); expect(summary?.scope).toEqual(domain); expect(summary?.period.from).toBe('2026-02-28T12:00:00.000Z'); expect(summary?.period.asOf).toBe('2026-05-31T12:00:00.000Z'); @@ -83,17 +90,36 @@ test('publishes summary/detail pairs for all scopes, deletes expired history and invalidVisits: 0 }); expect(sum(details!.daily)).toEqual(summary?.totals); - expect(details?.version).toBe(2); + expect(details?.version).toBe(3); expect(details?.browsers).toEqual([ { day: '2026-02-28', name: 'Firefox', validVisits: 1, invalidVisits: 0 }, - { day: '2026-05-31', name: 'Firefox', validVisits: 0, invalidVisits: 1 } + { day: '2026-05-31', name: 'Firefox', validVisits: 1, invalidVisits: 1 } ]); expect(details?.operatingSystems).toEqual([ { day: '2026-02-28', name: 'Linux', validVisits: 1, invalidVisits: 0 }, - { day: '2026-05-31', name: 'Linux', validVisits: 0, invalidVisits: 1 } + { day: '2026-05-31', name: 'Linux', validVisits: 1, 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); + expect(await ctx.db.selectFrom('visits').select('id').execute()).toHaveLength(3); + const rootDetails = await readVisitDetails(ctx.db, root); + expect((await readVisitSummary(ctx.db, root))?.totals).toEqual({ + validVisits: 1, + invalidVisits: 0 + }); + expect(sum(rootDetails!.daily)).toEqual({ validVisits: 1, invalidVisits: 0 }); + expect(rootDetails?.browsers).toEqual([ + { day: '2026-05-31', name: 'Firefox', validVisits: 1, invalidVisits: 0 } + ]); + expect(rootDetails?.operatingSystems).toEqual([ + { day: '2026-05-31', name: 'Linux', validVisits: 1, invalidVisits: 0 } + ]); + expect( + await ctx.db + .selectFrom('visits') + .select(['path', 'outcome']) + .where('path', '=', '/') + .execute() + ).toEqual([{ path: '/', outcome: 'invalid' }]); await prepareVisitReports(ctx, now); expect((await readVisitSummary(ctx.db, overview))?.totals).toEqual(summary?.totals); } finally { diff --git a/packages/analytics/src/decode.test.ts b/packages/analytics/src/decode.test.ts index 3bb18ef..8278a31 100644 --- a/packages/analytics/src/decode.test.ts +++ b/packages/analytics/src/decode.test.ts @@ -14,7 +14,7 @@ const summary: OverviewSummary = { domains: [] }; const details: VisitDetails = { - version: 2, + version: 3, scope, period, daily: visitDays(period).map((day, index) => ({ @@ -30,6 +30,12 @@ test('readers decode their self-contained document independently', () => { expect(parseVisitDetails(JSON.stringify(details), scope)).toEqual(details); }); +test('details reader rejects version 2 reports with legacy Root classification', () => { + expect(() => parseVisitDetails(JSON.stringify({ ...details, version: 2 }), scope)).toThrow( + 'Invalid analytics report' + ); +}); + test('details reader can require the same reporting policy and window', () => { expect(parseVisitDetails(JSON.stringify(details), scope, period)).toEqual(details); const nextPeriod = createVisitPeriod(3, Date.UTC(2026, 3, 2, 12)); diff --git a/packages/analytics/src/encode.test.ts b/packages/analytics/src/encode.test.ts index a99022e..16918f8 100644 --- a/packages/analytics/src/encode.test.ts +++ b/packages/analytics/src/encode.test.ts @@ -34,7 +34,7 @@ function report( return { summary, details: { - version: 2, + version: 3, scope, period, daily: visitDays(period).map((day, index) => ({ @@ -50,7 +50,11 @@ function report( } test('encodes each summary variant and explicit root without changing document shapes', () => { - const reports = [report(overview), report(domain), report(root, asOf, zeroVisitTotals())]; + const reports = [ + report(overview), + report(domain), + report(root, asOf, { validVisits: 2, invalidVisits: 0 }) + ]; const rows = encodeVisitReports(reports); for (const [index, prepared] of reports.entries()) { diff --git a/packages/analytics/src/reports.ts b/packages/analytics/src/reports.ts index 49b9384..15d5f0b 100644 --- a/packages/analytics/src/reports.ts +++ b/packages/analytics/src/reports.ts @@ -91,7 +91,7 @@ const dailyBrowserOsCounts = Type.Object( export const VisitDetailsSchema = Type.Object( { - version: Type.Literal(2), + version: Type.Literal(3), scope: ReportScopeSchema, period: ReportPeriodSchema, daily: Type.Array( diff --git a/packages/db/src/visit-reports/queries.test.ts b/packages/db/src/visit-reports/queries.test.ts index 9bfaa23..95da1f8 100644 --- a/packages/db/src/visit-reports/queries.test.ts +++ b/packages/db/src/visit-reports/queries.test.ts @@ -71,7 +71,7 @@ async function seedVisits(): Promise { .execute(); } -test('aggregates explicit UTC scope identities, outcomes and root subsets', async () => { +test('aggregates explicit UTC scopes with legacy Root outcomes normalized only in reports', async () => { await seedVisits(); const result = await aggregateVisitReports(db, since, asOf, inventory); @@ -93,10 +93,10 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn domain: null, path: null, day: '2026-02-01', - validVisits: 1, - invalidVisits: 4, - baseValidVisits: 1, - baseInvalidVisits: 2 + validVisits: 3, + invalidVisits: 2, + baseValidVisits: 3, + baseInvalidVisits: 0 }, { scopeKind: 'domain', @@ -113,10 +113,10 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn domain, path: null, day: '2026-02-01', - validVisits: 1, - invalidVisits: 2, - baseValidVisits: 1, - baseInvalidVisits: 1 + validVisits: 2, + invalidVisits: 1, + baseValidVisits: 2, + baseInvalidVisits: 0 }, { scopeKind: 'link', @@ -133,10 +133,10 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn domain, path: '/', day: '2026-02-01', - validVisits: 1, - invalidVisits: 1, - baseValidVisits: 1, - baseInvalidVisits: 1 + validVisits: 2, + invalidVisits: 0, + baseValidVisits: 2, + baseInvalidVisits: 0 }, { scopeKind: 'domain', @@ -176,8 +176,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn path: null, day: '2026-02-01', name: 'Firefox', - validVisits: 0, - invalidVisits: 3 + validVisits: 2, + invalidVisits: 1 }, { scopeKind: 'overview', @@ -214,8 +214,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn path: null, day: '2026-02-01', name: 'Linux', - validVisits: 0, - invalidVisits: 2 + validVisits: 2, + invalidVisits: 0 }, { scopeKind: 'overview', @@ -254,8 +254,8 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn path: '/', day: '2026-02-01', name: 'Firefox', - validVisits: 0, - invalidVisits: 1 + validVisits: 1, + invalidVisits: 0 }, { scopeKind: 'link', @@ -267,6 +267,54 @@ test('aggregates explicit UTC scope identities, outcomes and root subsets', asyn invalidVisits: 0 } ]); + expect(result.browsers).toEqual( + expect.arrayContaining([ + { + scopeKind: 'domain', + domain, + path: null, + day: '2026-02-01', + name: 'Firefox', + validVisits: 1, + invalidVisits: 0 + } + ]) + ); + expect(result.operatingSystems).toEqual( + expect.arrayContaining([ + { + scopeKind: 'domain', + domain, + path: null, + day: '2026-02-01', + name: 'Linux', + validVisits: 1, + invalidVisits: 0 + }, + { + scopeKind: 'link', + domain, + path: '/', + day: '2026-02-01', + name: 'Linux', + validVisits: 1, + invalidVisits: 0 + } + ]) + ); + expect( + await db + .selectFrom('visits') + .select(['path', 'outcome']) + .where('outcome', '=', 'invalid') + .orderBy('visited_at') + .execute() + ).toEqual([ + { path: '/', outcome: 'invalid' }, + { path: '/unconfigured', outcome: 'invalid' }, + { path: '/', outcome: 'invalid' }, + { path: '/docs', outcome: 'invalid' } + ]); expect( result.operatingSystems.filter( ({ scopeKind, domain: rowDomain }) => scopeKind === 'link' && rowDomain === otherDomain @@ -304,10 +352,10 @@ test('empty inventory keeps historical overview traffic but no domain or link sc domain: null, path: null, day: '2026-02-01', - validVisits: 1, - invalidVisits: 4, - baseValidVisits: 1, - baseInvalidVisits: 2 + validVisits: 3, + invalidVisits: 2, + baseValidVisits: 3, + baseInvalidVisits: 0 } ]); expect( diff --git a/packages/db/src/visit-reports/queries.ts b/packages/db/src/visit-reports/queries.ts index 20be43d..35ac6f7 100644 --- a/packages/db/src/visit-reports/queries.ts +++ b/packages/db/src/visit-reports/queries.ts @@ -62,7 +62,8 @@ function scopedVisits(since: number, asOf: number, inventory: ReportInventory) { const currentLink = linkRules.length === 0 ? sql`0` : sql.join(linkRules, sql` OR `); return sql` WITH retained_visits AS ( - SELECT domain, path, outcome, visited_at, browser, os + SELECT domain, path, CASE WHEN path = '/' THEN 'valid' ELSE outcome END AS outcome, + visited_at, browser, os FROM visits WHERE visited_at >= ${since} AND visited_at < ${asOf} ), scoped_visits AS ( diff --git a/packages/db/src/visit-reports/storage.test.ts b/packages/db/src/visit-reports/storage.test.ts index 8564ec4..deaa1f4 100644 --- a/packages/db/src/visit-reports/storage.test.ts +++ b/packages/db/src/visit-reports/storage.test.ts @@ -56,7 +56,7 @@ function preparedReport( return { summary, details: { - version: 2, + version: 3, scope, period, daily: visitDays(period).map((day, index) => ({ @@ -75,7 +75,7 @@ test('reads summary and details for each explicit SQL scope including root', asy const reports = [ preparedReport(overviewScope, asOf, { validVisits: 2, invalidVisits: 1 }), preparedReport(domainScope, asOf, { validVisits: 1, invalidVisits: 1 }), - preparedReport(rootScope) + preparedReport(rootScope, asOf, { validVisits: 2, invalidVisits: 0 }) ]; await publishVisitReports(db, encodeVisitReports(reports)); diff --git a/scripts/seed-visits-data.js b/scripts/seed-visits-data.js index aedaf73..1fae19d 100644 --- a/scripts/seed-visits-data.js +++ b/scripts/seed-visits-data.js @@ -113,7 +113,7 @@ function requestPaths(links) { } function visitOutcome(prepared, path, visitedAt) { - if (path === '/') return prepared.root ? 'valid' : 'invalid'; + if (path === '/') return 'valid'; const link = prepared.links[path]; if (!link || !link.enabled) return 'invalid'; if (link.valid_from !== undefined && visitedAt < Date.parse(link.valid_from)) return 'invalid'; diff --git a/scripts/seed-visits.test.js b/scripts/seed-visits.test.js index e26156d..8367cd6 100644 --- a/scripts/seed-visits.test.js +++ b/scripts/seed-visits.test.js @@ -91,7 +91,7 @@ test('fixed inputs produce repeatable retained traffic with request-time outcome assert(visit.user_agent.endsWith(' GotoSeed/42')); assert.deepEqual(classifyUserAgent(visit.user_agent), { browser: visit.browser, os: visit.os }); if (visit.path === '/') { - assert.equal(visit.outcome, visit.domain === 'one.example.test' ? 'valid' : 'invalid'); + assert.equal(visit.outcome, 'valid'); } if (visit.path === '/future') assert.equal(visit.outcome, 'invalid'); }