From a114c26a68ec8c653b186a96e9484758ff53db9d Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 10 Nov 2026 14:40:08 -0800 Subject: [PATCH 1/2] feat(admin): add domain visit charts --- .changeset/domain-visits-chart.md | 7 + apps/web/e2e/auth.e2e.ts | 46 +++- .../components/admin/VisitTimeframes.svelte | 60 +++++ apps/web/src/lib/server/visit-reports.test.ts | 50 +++- apps/web/src/lib/server/visit-reports.ts | 25 +- apps/web/src/lib/styles/admin.css | 11 - .../(admin)/(auth)/admin/+page.server.ts | 29 +-- .../routes/(admin)/(auth)/admin/+page.svelte | 53 ++--- .../admin/domains/[domain]/+page.server.ts | 84 ++++--- .../admin/domains/[domain]/+page.svelte | 64 ++++- .../domains/[domain]/page.server.test.ts | 223 +++++++++++++----- .../(admin)/(auth)/admin/page.server.test.ts | 37 ++- packages/analytics/src/daily.test.ts | 65 ++++- packages/analytics/src/daily.ts | 35 ++- packages/db/src/visit-reports/index.ts | 3 +- packages/db/src/visit-reports/storage.test.ts | 110 +++++++++ packages/db/src/visit-reports/storage.ts | 41 ++++ 17 files changed, 730 insertions(+), 213 deletions(-) create mode 100644 .changeset/domain-visits-chart.md create mode 100644 apps/web/src/lib/components/admin/VisitTimeframes.svelte diff --git a/.changeset/domain-visits-chart.md b/.changeset/domain-visits-chart.md new file mode 100644 index 0000000..b1db52c --- /dev/null +++ b/.changeset/domain-visits-chart.md @@ -0,0 +1,7 @@ +--- +'@goto/web': minor +'@goto/worker': minor +'@goto/cli': minor +--- + +Add the visits chart and retention-aware timeframes to domain dashboards. Reuse the overview chart, timeframe control and window-count helpers so domain traffic cards, base URL and current-link counts follow one selected window. Preserve the selected timeframe between overview and domain pages, keep link series out of browser data, and show inventory only when analytics is disabled. Individual link pages remain unchanged. diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 835612b..661d8f0 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -101,7 +101,16 @@ test('shared credentials protect overview, domain and link pages and sign-out re expect(navigationWarnings).toEqual([]); await domainLink.click(); await expect(page.getByRole('heading', { name: 'go.example.org', exact: true })).toBeVisible(); - await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(0); + const overviewBreadcrumb = page + .getByRole('navigation', { name: 'Breadcrumb', exact: true }) + .getByRole('link', { name: 'Overview', exact: true }); + await expect(overviewBreadcrumb).toHaveAttribute('href', '/admin?period=all'); + await expect(trend).toHaveAttribute('aria-busy', 'false'); + await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 2\./); + await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ); await expect(visitsValue).toHaveText('2'); await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([ 'Links', @@ -116,6 +125,18 @@ test('shared credentials protect overview, domain and link pages and sign-out re const baseTable = page.getByRole('region', { name: 'Base URL on go.example.org', exact: true }); const columns = ['Path', 'Destination', 'State', 'Visits']; await expect(linksTable.getByRole('columnheader')).toHaveText(columns); + const retainedLink = linksTable + .getByRole('row') + .filter({ has: page.getByRole('link', { name: '/a%2Fb', exact: true }) }); + await expect(retainedLink.getByRole('cell').last()).toHaveText('1'); + await timeframe.getByRole('button', { name: '30 days', exact: true }).click(); + await expect(visitsValue).toHaveText('1'); + await expect(retainedLink.getByRole('cell').last()).toHaveText('0'); + await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 1\./); + await timeframe.getByRole('button', { name: '3 months', exact: true }).click(); + await expect(visitsValue).toHaveText('2'); + await expect(retainedLink.getByRole('cell').last()).toHaveText('1'); + await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 2\./); const historicalInvalid = linksTable .getByRole('row') .filter({ has: page.getByRole('link', { name: '/a/b', exact: true }) }); @@ -133,6 +154,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re await page.getByRole('link', { name: '/docs', exact: true }).click(); await expect(page).toHaveURL(`${appServer.origin}${docsPath}`); await expect(page.getByRole('heading', { name: '/docs', exact: true })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(0); await expect(visitsValue).toHaveText('1'); await expect( page.getByRole('region', { name: 'Links on go.example.org', exact: true }) @@ -155,7 +177,11 @@ test('shared credentials protect overview, domain and link pages and sign-out re await expect(page.getByRole('heading', { name: '/', exact: true })).toBeVisible(); await expect(visitsValue).toHaveText('0'); await domainBreadcrumb.click(); - await expect(visitsValue).toHaveText('2'); + await expect(timeframe.getByRole('button', { name: '30 days', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(visitsValue).toHaveText('1'); const html = await page.content(); await expect(html).not.toContain('e2e-only-password'); @@ -187,5 +213,21 @@ test.describe('analytics disabled', () => { page.getByRole('region', { name: 'Domain inventory', exact: true }).getByRole('columnheader') ).toHaveText(['Domain', 'Links']); await expect(page.getByRole('status')).toHaveText('Analytics disabled.'); + await page.getByRole('link', { name: 'go.example.org', exact: true }).click(); + await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText(['Links']); + await expect(page.getByRole('group', { name: 'Timeframe', exact: true })).toHaveCount(0); + await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount( + 0 + ); + await expect( + page + .getByRole('region', { name: 'Links on go.example.org', exact: true }) + .getByRole('columnheader') + ).toHaveText(['Path', 'Destination', 'State']); + await expect( + page + .getByRole('region', { name: 'Base URL on go.example.org', exact: true }) + .getByRole('columnheader') + ).toHaveText(['Path', 'Destination', 'State']); }); }); diff --git a/apps/web/src/lib/components/admin/VisitTimeframes.svelte b/apps/web/src/lib/components/admin/VisitTimeframes.svelte new file mode 100644 index 0000000..2328a3e --- /dev/null +++ b/apps/web/src/lib/components/admin/VisitTimeframes.svelte @@ -0,0 +1,60 @@ + + +
+ {#each timeframes as timeframe (timeframe)} + + {/each} +
+ + diff --git a/apps/web/src/lib/server/visit-reports.test.ts b/apps/web/src/lib/server/visit-reports.test.ts index da64526..f54b4ad 100644 --- a/apps/web/src/lib/server/visit-reports.test.ts +++ b/apps/web/src/lib/server/visit-reports.test.ts @@ -13,7 +13,7 @@ import type { GotoConfiguration } from '@goto/config'; import { openDb, runMigrations } from '@goto/db'; import { publishVisitReports } from '@goto/db/visit-reports'; import { expect, test, vi } from 'vitest'; -import { readOverviewDetails, readVisitSummary } from './visit-reports.js'; +import { readOverviewDetails, readDomainDetails, readVisitSummary } from './visit-reports.js'; test('reads prepared output and hides snapshots from a different retention policy', async () => { const directory = await mkdtemp(join(tmpdir(), 'goto-page-reports-')); @@ -50,11 +50,14 @@ test('reads prepared output and hides snapshots from a different retention polic const domain = 'go.example.test'; const domainScope = { kind: 'domain', domain } as const; const domainDetails: VisitDetails = { ...details, scope: domainScope }; + const linkScope = { kind: 'link', domain, path: '/docs' } as const; + const linkDetails: VisitDetails = { ...details, scope: linkScope }; try { const db = await openDb(configuration.database.path); try { await runMigrations(db); expect(await readOverviewDetails(configuration, [domain])).toBeNull(); + expect(await readDomainDetails(configuration, domain, ['/docs'])).toBeNull(); await publishVisitReports( db, encodeVisitReports([ @@ -69,6 +72,10 @@ test('reads prepared output and hides snapshots from a different retention polic links: [] }, details: domainDetails + }, + { + summary: { version: 1, scope: linkScope, period, totals }, + details: linkDetails } ]) ); @@ -83,9 +90,16 @@ test('reads prepared output and hides snapshots from a different retention polic domains: [{ domain, details: domainDetails }] }); expect(close).toHaveBeenCalledTimes(1); + expect(await readDomainDetails(configuration, domain, ['/docs', '/new'])).toEqual({ + domain: domainDetails, + links: [{ path: '/docs', details: linkDetails }] + }); + expect(close).toHaveBeenCalledTimes(2); configuration.analytics.retention_months = 9; expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull(); - expect(close).toHaveBeenCalledTimes(2); + expect(close).toHaveBeenCalledTimes(3); + expect(await readDomainDetails(configuration, domain, ['/docs'])).toBeNull(); + expect(close).toHaveBeenCalledTimes(4); } finally { close.mockRestore(); } @@ -111,13 +125,22 @@ test('invalid prepared output is unavailable, sanitized and closed', async () => await runMigrations(db); await db .insertInto('analytics_reports') - .values({ - scope_kind: 'overview', - domain: null, - path: null, - summary_json: '{"private":"not-for-browser"}', - details_json: '{}' - }) + .values([ + { + scope_kind: 'overview', + domain: null, + path: null, + summary_json: '{"private":"not-for-browser"}', + details_json: '{}' + }, + { + scope_kind: 'domain', + domain: 'go.example.test', + path: null, + summary_json: '{"private":"not-for-browser"}', + details_json: '{}' + } + ]) .execute(); } finally { await db.destroy(); @@ -127,6 +150,12 @@ test('invalid prepared output is unavailable, sanitized and closed', async () => expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull(); expect(close).toHaveBeenCalledTimes(1); expect(warning).toHaveBeenCalledExactlyOnceWith('Prepared visit reports unavailable'); + expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull(); + expect(close).toHaveBeenCalledTimes(2); + expect(warning.mock.calls).toEqual([ + ['Prepared visit reports unavailable'], + ['Prepared visit reports unavailable'] + ]); } finally { close.mockRestore(); } @@ -150,7 +179,9 @@ test('enabled collection fails safely without creating missing storage', async ( }; expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull(); expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull(); + expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull(); expect(warning.mock.calls).toEqual([ + ['Prepared visit reports unavailable'], ['Prepared visit reports unavailable'], ['Prepared visit reports unavailable'] ]); @@ -170,6 +201,7 @@ test('disabled collection does not attempt to open missing storage', async () => }; expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull(); expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull(); + expect(await readDomainDetails(configuration, 'go.example.test', ['/docs'])).toBeNull(); expect(warning).not.toHaveBeenCalled(); } finally { warning.mockRestore(); diff --git a/apps/web/src/lib/server/visit-reports.ts b/apps/web/src/lib/server/visit-reports.ts index d00070b..7af2419 100644 --- a/apps/web/src/lib/server/visit-reports.ts +++ b/apps/web/src/lib/server/visit-reports.ts @@ -3,8 +3,10 @@ import type { GotoConfiguration } from '@goto/config'; import { openDb } from '@goto/db'; import { readOverviewVisitDetails, + readDomainVisitDetails, readVisitSummary as readPreparedSummary, - type OverviewVisitDetails + type OverviewVisitDetails, + type DomainVisitDetails } from '@goto/db/visit-reports'; import type { Immutable } from './snapshot.js'; @@ -30,6 +32,27 @@ export async function readOverviewDetails( } } +export async function readDomainDetails( + configuration: ReportConfiguration, + domain: string, + currentPaths: string[] +): Promise { + const months = configuration.analytics.retention_months; + if (months === 0) return null; + let db: Awaited> | undefined; + try { + db = await openDb(configuration.database.path, { readOnly: true }); + const report = await readDomainVisitDetails(db, domain, currentPaths); + if (!report || report.domain.period.retentionMonths !== months) return null; + return report; + } catch { + warnUnavailable(); + return null; + } finally { + await db?.destroy().catch(warnUnavailable); + } +} + export async function readVisitSummary( configuration: ReportConfiguration, scope: ReportScope diff --git a/apps/web/src/lib/styles/admin.css b/apps/web/src/lib/styles/admin.css index fbfdfe3..270a61e 100644 --- a/apps/web/src/lib/styles/admin.css +++ b/apps/web/src/lib/styles/admin.css @@ -52,17 +52,6 @@ .admin-cards-inventory { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.overview-timeframes { - margin-inline-start: auto; - display: flex; - flex-wrap: wrap; - gap: var(--space-1); -} -.overview-timeframes button[aria-pressed='true'] { - font-weight: 600; - background: var(--color-surface-muted); - box-shadow: inset 0 0 0 var(--border-width) var(--color-border-strong); -} .admin-stat, .admin-info { background: var(--color-surface-card); diff --git a/apps/web/src/routes/(admin)/(auth)/admin/+page.server.ts b/apps/web/src/routes/(admin)/(auth)/admin/+page.server.ts index 217efe3..5a625b4 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/+page.server.ts +++ b/apps/web/src/routes/(admin)/(auth)/admin/+page.server.ts @@ -1,11 +1,4 @@ -import type { VisitDetails } from '@goto/analytics'; -import { - availableVisitTimeframes, - sumValidVisits, - visitWindow, - type VisitTimeframe, - type VisitWindow -} from '@goto/analytics/daily'; +import { availableVisitTimeframes, countVisitWindows, visitWindow } from '@goto/analytics/daily'; import { readOverviewDetails } from '#lib/server/visit-reports.js'; import type { PageServerLoad } from './$types'; @@ -29,16 +22,16 @@ export const load = (async ({ locals }) => { const detailsByDomain = new Map(report?.domains.map(({ domain, details }) => [domain, details])); const domains = Object.entries(snapshot.domains).map(([domain, prepared]) => { const daily = detailsByDomain.get(domain)?.daily; - const visitsByTimeframe = countVisitWindows(daily, windows); + const countsByTimeframe = countVisitWindows(daily, windows); return { domain, linkCount: Object.keys(prepared.links).length, - visitsByTimeframe, + countsByTimeframe, href: `/admin/domains/${encodeURIComponent(domain)}` }; }); const asOf = overview ? Date.parse(overview.period.asOf) : null; - const visitsByTimeframe = countVisitWindows(overview?.daily, windows); + const countsByTimeframe = countVisitWindows(overview?.daily, windows); const chart = overview ? { daily: overview.daily, period: overview.period } : null; return { collectionEnabled, @@ -48,20 +41,8 @@ export const load = (async ({ locals }) => { domainCount: domains.length, linkCount: domains.reduce((sum, row) => sum + row.linkCount, 0), timeframes, - visitsByTimeframe, + countsByTimeframe, chart, domains }; }) satisfies PageServerLoad; - -function countVisitWindows( - daily: VisitDetails['daily'] | undefined, - windows: Array<{ timeframe: VisitTimeframe; window: VisitWindow }> -): Partial> { - const counts: Partial> = {}; - if (!daily) return counts; - for (const { timeframe, window } of windows) { - counts[timeframe] = sumValidVisits(daily, window); - } - return counts; -} diff --git a/apps/web/src/routes/(admin)/(auth)/admin/+page.svelte b/apps/web/src/routes/(admin)/(auth)/admin/+page.svelte index 73e9730..535215b 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/+page.svelte +++ b/apps/web/src/routes/(admin)/(auth)/admin/+page.svelte @@ -1,61 +1,34 @@ {#snippet actions()} {#if data.available && data.timeframes.length > 0} -
- {#each data.timeframes as option (option)} - - {/each} -
+ {/if} {/snippet} @@ -90,10 +63,14 @@ {#each data.domains as row (row.domain)} - {row.domain} + {row.domain} {formatCount(row.linkCount)} {#if data.collectionEnabled}{formatCount(row.visitsByTimeframe[timeframe] ?? null)}{formatCount(row.countsByTimeframe[timeframe]?.validVisits ?? null)}{/if} {/each} 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 48d27f5..a429e38 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,32 +1,58 @@ import { error } from '@sveltejs/kit'; -import type { DomainSummary, VisitTotals } from '@goto/analytics'; +import { + availableVisitTimeframes, + countVisitWindows, + visitWindow, + type VisitTimeframe +} from '@goto/analytics/daily'; import { currentLinkState } from '#lib/server/link-state.js'; -import { readVisitSummary } from '#lib/server/visit-reports.js'; +import { readDomainDetails } from '#lib/server/visit-reports.js'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals, params }) => { +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 configuration = snapshot.configuration; - const collectionEnabled = configuration.analytics.retention_months !== 0; - let domainSummary: DomainSummary | null = null; - if (collectionEnabled) { - const scope = { kind: 'domain' as const, domain }; - const summary = await readVisitSummary(configuration, scope); - if (summary && 'links' in summary) domainSummary = summary; + const retentionMonths = configuration.analytics.retention_months; + const collectionEnabled = retentionMonths !== 0; + const currentPaths = Object.keys(prepared.links); + const report = collectionEnabled + ? await readDomainDetails(configuration, domain, currentPaths) + : null; + const domainDetails = report?.domain ?? null; + const timeframes = retentionMonths === 0 ? [] : availableVisitTimeframes(retentionMonths); + const windows = domainDetails + ? timeframes.map((timeframe) => ({ + timeframe, + window: visitWindow(domainDetails.period, timeframe) + })) + : []; + const detailsByPath = new Map(report?.links.map(({ path, details }) => [path, details])); + const countsByTimeframe = countVisitWindows(domainDetails?.daily, 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; } - const countsByPath = new Map(); - for (const row of domainSummary?.links ?? []) countsByPath.set(row.path, row.totals); + const now = Date.now(); - const links = Object.entries(prepared.links).map(([path, link]) => ({ - path, - url: link.url, - state: currentLinkState(link, now), - counts: countsByPath.get(path) ?? null, - href: linkHref(domain, path) - })); + 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 root = prepared.links['/']; let baseUrl = prepared.fallback === 404 ? null : prepared.fallback; let baseState = 'Fallback'; @@ -34,30 +60,28 @@ export const load: PageServerLoad = async ({ locals, params }) => { baseUrl = root.url; baseState = 'Active'; } - let invalidClicks: number | null = null; - if (domainSummary) { - invalidClicks = domainSummary.totals.invalidVisits - domainSummary.base.invalidVisits; - } - - const asOf = domainSummary ? Date.parse(domainSummary.period.asOf) : null; + const asOf = domainDetails ? Date.parse(domainDetails.period.asOf) : null; + const chart = domainDetails ? { daily: domainDetails.daily, period: domainDetails.period } : null; return { domain, - retentionMonths: configuration.analytics.retention_months, + retentionMonths, collectionEnabled, - available: domainSummary !== null, + available: domainDetails !== null, asOf, linkCount: links.length, - totals: domainSummary?.totals ?? null, - invalidClicks, + timeframes, + countsByTimeframe, + invalidClicksByTimeframe, + chart, base: { url: baseUrl, state: baseState, - counts: domainSummary?.base ?? null, + countsByTimeframe: baseCountsByTimeframe, href: linkHref(domain, '/') }, links }; -}; +}) satisfies PageServerLoad; function linkHref(domain: string, path: string): string { const base = `/admin/domains/${encodeURIComponent(domain)}/links`; 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 a9043f4..565c6ff 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 @@ -1,29 +1,66 @@ - + + {#snippet actions()} + {#if data.available && data.timeframes.length > 0} + + {/if} + {/snippet} -
+
- - - + {#if data.collectionEnabled} + + + + {/if}
+ {#if data.chart && window} + + {/if}

Base URL

{#if data.collectionEnabled}{/if} @@ -35,7 +72,9 @@ >{:else}404 Not found{/if} - + {#if data.collectionEnabled}{/if} @@ -50,7 +89,7 @@ PathDestinationStateVisits{#if data.collectionEnabled}Visits{/if} @@ -60,7 +99,10 @@ >{link.path} {link.url} - {link.state}{formatCount(link.counts?.validVisits ?? null)} + {link.state} + {#if data.collectionEnabled}{formatCount(link.countsByTimeframe[timeframe]?.validVisits ?? null)}{/if} {/each} 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 8fd1391..a17081b 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,15 +1,17 @@ import { validateConfig } from '@goto/config'; -import type { DomainSummary } from '@goto/analytics'; +import { createVisitPeriod, visitDays, type ReportScope, type VisitDetails } from '@goto/analytics'; import { beforeEach, expect, test, vi } from 'vitest'; import { prepareSnapshot } from '#lib/server/snapshot.js'; -import { readVisitSummary } from '#lib/server/visit-reports.js'; +import { readDomainDetails } from '#lib/server/visit-reports.js'; import { load } from './+page.server.js'; -vi.mock('#lib/server/visit-reports.js', () => ({ readVisitSummary: vi.fn() })); +vi.mock('#lib/server/visit-reports.js', () => ({ readDomainDetails: vi.fn() })); beforeEach(() => vi.resetAllMocks()); const domain = 'go.example.test'; -function request(host = domain, rootUrl?: string) { +const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z')); + +function request(host = domain, rootUrl?: string, retentionMonths = 3) { const links: Record = { '/docs': { url: 'https://example.test/docs' }, '/a/b': { url: 'https://example.test/nested' }, @@ -19,6 +21,7 @@ function request(host = domain, rootUrl?: string) { const prepared = validateConfig( { auth: { credentials: { username: 'test', password: 'domain-test-password' } }, + analytics: { retention_months: retentionMonths }, domains: { [domain]: { links } } }, { env: {} } @@ -28,83 +31,175 @@ function request(host = domain, rootUrl?: string) { params: { domain: host } } as Parameters[0]; } -const docs = { validVisits: 2, invalidVisits: 1 }; -const root = { validVisits: 0, invalidVisits: 1 }; -const empty = { validVisits: 0, invalidVisits: 0 }; -const summary: DomainSummary = { - version: 1, - scope: { kind: 'domain', domain }, - period: { - from: '2026-03-15T12:00:00.000Z', - asOf: '2026-06-15T12:00:00.000Z', - timezone: 'UTC', - retentionMonths: 3 - }, - totals: { validVisits: 3, invalidVisits: 3 }, - base: root, + +function details(scope: ReportScope, counts: Record): VisitDetails { + const daily = visitDays(period).map((day) => { + 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) + }; + return { + version: 1, + scope, + period, + daily, + browsers: [{ family: 'Other', ...totals }], + operatingSystems: [{ family: 'Other', ...totals }] + }; +} + +const domainDetails = details( + { kind: 'domain', domain }, + { + '2026-03-15': [3, 2], + '2026-05-16': [5, 4], + '2026-06-08': [2, 3], + '2026-06-09': [1, 2], + '2026-06-15': [4, 3] + } +); +const baseDetails = 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] + } +); +const docsDetails = details( + { kind: 'link', domain, path: '/docs' }, + { + '2026-05-16': [5, 1], + '2026-06-09': [1, 0], + '2026-06-15': [2, 0] + } +); +const emptyDetails = details({ kind: 'link', domain, path: '/a%2Fb' }, {}); +const report = { + domain: domainDetails, links: [ - { path: '/docs', totals: docs }, - { path: '/', totals: root }, - { path: '/a%2Fb', totals: empty } + { path: '/', details: baseDetails }, + { 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 } +}; -test('reads only domain-wide traffic and links to dedicated path routes', async () => { - vi.mocked(readVisitSummary).mockResolvedValue(summary); +test('projects consistent domain, base and link windows without exposing link series or families', async () => { + vi.mocked(readDomainDetails).mockResolvedValue(report); const data = await load(request()); - if (!data) throw new Error('Missing page data'); - expect(readVisitSummary).toHaveBeenCalledExactlyOnceWith(expect.any(Object), { - kind: 'domain', - domain - }); - expect(data.totals).toEqual(summary.totals); - expect(data.invalidClicks).toBe(2); - expect(data.linkCount).toBe(3); - expect(data.retentionMonths).toBe(3); - expect(data.links).toMatchObject([ - { href: '/admin/domains/go.example.test/links/docs', counts: docs }, - { href: '/admin/domains/go.example.test/links/a/b' }, - { href: '/admin/domains/go.example.test/links/a%252Fb', counts: empty, state: 'Disabled' } + expect(readDomainDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), domain, [ + '/docs', + '/a/b', + '/a%2Fb' ]); - expect(data.base.href).toBe('/admin/domains/go.example.test/links'); - expect(data.base.url).toBeNull(); - expect(data.base.state).toBe('Fallback'); - expect(data.base.counts).toEqual(root); - expect(data.asOf).toBe(Date.parse(summary.period.asOf)); + expect(data).toEqual({ + domain, + retentionMonths: 3, + collectionEnabled: true, + available: true, + asOf: Date.parse(period.asOf), + linkCount: 3, + timeframes: ['7d', '30d', 'all'], + countsByTimeframe: { + '7d': { validVisits: 5, invalidVisits: 5 }, + '30d': { validVisits: 7, invalidVisits: 8 }, + all: { validVisits: 15, invalidVisits: 14 } + }, + invalidClicksByTimeframe: { '7d': 3, '30d': 5, all: 10 }, + chart: { daily: domainDetails.daily, period }, + base: { + url: null, + state: 'Fallback', + countsByTimeframe: baseCounts, + href: '/admin/domains/go.example.test/links' + }, + links: [ + { + path: '/docs', + url: 'https://example.test/docs', + state: 'Active', + countsByTimeframe: { + '7d': { validVisits: 3, invalidVisits: 0 }, + '30d': { validVisits: 3, invalidVisits: 0 }, + all: { validVisits: 8, invalidVisits: 1 } + }, + href: '/admin/domains/go.example.test/links/docs' + }, + { + path: '/a/b', + url: 'https://example.test/nested', + state: 'Active', + countsByTimeframe: {}, + href: '/admin/domains/go.example.test/links/a/b' + }, + { + path: '/a%2Fb', + url: 'https://example.test/percent', + state: 'Disabled', + countsByTimeframe: { + '7d': { validVisits: 0, invalidVisits: 0 }, + '30d': { validVisits: 0, invalidVisits: 0 }, + all: { validVisits: 0, invalidVisits: 0 } + }, + href: '/admin/domains/go.example.test/links/a%252Fb' + } + ] + }); }); -test('shows an active base destination without reclassifying historical outcomes', async () => { - vi.mocked(readVisitSummary).mockResolvedValue(summary); +test('shows an active root destination without reclassifying retained outcomes or subtracting valid root traffic', async () => { + vi.mocked(readDomainDetails).mockResolvedValue(report); const data = await load(request(domain, 'https://example.test/start')); - if (!data) throw new Error('Missing base page data'); expect(data.base.url).toBe('https://example.test/start'); expect(data.base.state).toBe('Active'); - expect(data.base.counts).toEqual(root); + expect(data.base.countsByTimeframe).toEqual(baseCounts); + expect(data.countsByTimeframe.all?.validVisits).toBe(15); + expect(data.invalidClicksByTimeframe.all).toBe(10); + expect(data.linkCount).toBe(4); }); -test('excludes invalid base traffic without subtracting valid base redirects', async () => { - const mixedRoot = { validVisits: 3, invalidVisits: 2 }; - const mixedSummary: DomainSummary = { - ...summary, - totals: { validVisits: 6, invalidVisits: 4 }, - base: mixedRoot, - links: summary.links.map((row) => (row.path === '/' ? { ...row, totals: mixedRoot } : row)) - }; - vi.mocked(readVisitSummary).mockResolvedValue(mixedSummary); +test('missing base details leave base and non-base invalid counts unavailable, not zero', async () => { + vi.mocked(readDomainDetails).mockResolvedValue({ + domain: domainDetails, + links: [{ path: '/docs', details: docsDetails }] + }); const data = await load(request()); - if (!data) throw new Error('Missing domain page data'); - expect(data.invalidClicks).toBe(2); - expect(data.totals?.validVisits).toBe(6); + expect(data.available).toBe(true); + expect(data.base.countsByTimeframe).toEqual({}); + expect(data.invalidClicksByTimeframe).toEqual({}); + expect(data.countsByTimeframe.all?.validVisits).toBe(15); }); -test('rejects unknown domains before reading and preserves unavailable rather than zero', async () => { +test('rejects unknown domains before reading and keeps unavailable inventory usable', async () => { await expect(load(request('unknown.test'))).rejects.toMatchObject({ status: 404 }); - expect(readVisitSummary).not.toHaveBeenCalled(); - vi.mocked(readVisitSummary).mockResolvedValue(null); + expect(readDomainDetails).not.toHaveBeenCalled(); + vi.mocked(readDomainDetails).mockResolvedValue(null); const data = await load(request()); - if (!data) throw new Error('Missing unavailable page'); expect(data.available).toBe(false); - expect(data.totals).toBeNull(); - expect(data.invalidClicks).toBeNull(); - expect(data.links[0]?.counts).toBeNull(); + expect(data.chart).toBeNull(); + expect(data.countsByTimeframe).toEqual({}); + expect(data.invalidClicksByTimeframe).toEqual({}); + expect(data.links[0]?.countsByTimeframe).toEqual({}); + expect(data.linkCount).toBe(3); +}); + +test('disabled collection reads no reports and preserves current inventory', async () => { + const data = await load(request(domain, undefined, 0)); + expect(readDomainDetails).not.toHaveBeenCalled(); + expect(data.collectionEnabled).toBe(false); + expect(data.timeframes).toEqual([]); + expect(data.chart).toBeNull(); + expect(data.countsByTimeframe).toEqual({}); + expect(data.invalidClicksByTimeframe).toEqual({}); + expect(data.base.state).toBe('Fallback'); + expect(data.linkCount).toBe(3); }); 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 f75dd17..53e80c1 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 @@ -106,25 +106,37 @@ test('projects compact global window counts while inventory stays current and hi domainCount: 3, linkCount: 4, timeframes: ['7d', '30d', 'all'], - visitsByTimeframe: { '7d': 3, '30d': 11, all: 42 }, + countsByTimeframe: { + '7d': { validVisits: 3, invalidVisits: 7 }, + '30d': { validVisits: 11, invalidVisits: 30 }, + all: { validVisits: 42, invalidVisits: 93 } + }, chart: { daily: overview.daily, period }, domains: [ { domain, linkCount: 2, - visitsByTimeframe: { '7d': 2, '30d': 5, all: 12 }, + countsByTimeframe: { + '7d': { validVisits: 2, invalidVisits: 7 }, + '30d': { validVisits: 5, invalidVisits: 30 }, + all: { validVisits: 12, invalidVisits: 93 } + }, href: '/admin/domains/go.example.test' }, { domain: emptyDomain, linkCount: 1, - visitsByTimeframe: { '7d': 0, '30d': 0, all: 0 }, + countsByTimeframe: { + '7d': { validVisits: 0, invalidVisits: 7 }, + '30d': { validVisits: 0, invalidVisits: 30 }, + all: { validVisits: 0, invalidVisits: 93 } + }, href: '/admin/domains/empty.example.test' }, { domain: newDomain, linkCount: 1, - visitsByTimeframe: {}, + countsByTimeframe: {}, href: '/admin/domains/new.example.test' } ] @@ -143,8 +155,13 @@ test.each([ const data = await load(request(months)); if (!data) throw new Error('Missing overview page'); expect(data.timeframes).toEqual(['7d', '30d', '90d', 'all']); - expect(data.visitsByTimeframe).toEqual({ '7d': 7, '30d': 30, '90d': 90, all: retainedDays }); - expect(data.domains.every((row) => Object.keys(row.visitsByTimeframe).length === 0)).toBe(true); + expect(data.countsByTimeframe).toEqual({ + '7d': { validVisits: 7, invalidVisits: 7 }, + '30d': { validVisits: 30, invalidVisits: 30 }, + '90d': { validVisits: 90, invalidVisits: 90 }, + all: { validVisits: retainedDays, invalidVisits: retainedDays } + }); + expect(data.domains.every((row) => Object.keys(row.countsByTimeframe).length === 0)).toBe(true); } ); @@ -159,8 +176,8 @@ test('missing preparation keeps inventory usable without substituting zero count expect(data.linkCount).toBe(4); expect(data.asOf).toBeNull(); expect(data.chart).toBeNull(); - expect(data.visitsByTimeframe).toEqual({}); - expect(data.domains.map((row) => row.visitsByTimeframe)).toEqual([{}, {}, {}]); + expect(data.countsByTimeframe).toEqual({}); + expect(data.domains.map((row) => row.countsByTimeframe)).toEqual([{}, {}, {}]); }); test('disabled collection reads no analytics and exposes only inventory with empty counts', async () => { @@ -171,8 +188,8 @@ test('disabled collection reads no analytics and exposes only inventory with emp expect(data.domainCount).toBe(3); expect(data.linkCount).toBe(4); expect(data.timeframes).toEqual([]); - expect(data.visitsByTimeframe).toEqual({}); + expect(data.countsByTimeframe).toEqual({}); expect(data.chart).toBeNull(); - expect(data.domains.map((row) => row.visitsByTimeframe)).toEqual([{}, {}, {}]); + expect(data.domains.map((row) => row.countsByTimeframe)).toEqual([{}, {}, {}]); expect(readOverviewDetails).not.toHaveBeenCalled(); }); diff --git a/packages/analytics/src/daily.test.ts b/packages/analytics/src/daily.test.ts index b0e3d7d..ac93772 100644 --- a/packages/analytics/src/daily.test.ts +++ b/packages/analytics/src/daily.test.ts @@ -1,10 +1,13 @@ import { expect, test } from 'vitest'; import { availableVisitTimeframes, + countVisitWindows, groupVisitDays, - sumValidVisits, + resolveVisitTimeframe, + sumVisitTotals, visitGranularity, - visitWindow + visitWindow, + type VisitTimeframe } from './daily.js'; import { visitDays } from './period.js'; import type { ReportPeriod, VisitDetails } from './reports.js'; @@ -34,6 +37,17 @@ test('timeframe choices and automatic granularity follow retention', () => { expect(visitGranularity('all', 12)).toBe('month'); }); +test('requested timeframes must be available and otherwise default to thirty days', () => { + const available = availableVisitTimeframes(3); + + expect(resolveVisitTimeframe(available, '7d')).toBe('7d'); + expect(resolveVisitTimeframe(available, 'all')).toBe('all'); + expect(resolveVisitTimeframe(availableVisitTimeframes(9), '90d')).toBe('90d'); + expect(resolveVisitTimeframe(available, '90d')).toBe('30d'); + expect(resolveVisitTimeframe(available, 'unknown')).toBe('30d'); + expect(resolveVisitTimeframe(available, null)).toBe('30d'); +}); + test('day presets include the latest UTC calendar day rather than rolling hours', () => { const period: ReportPeriod = { from: '2023-12-01T12:00:00.000Z', @@ -71,7 +85,7 @@ test('full windows preserve partial retention boundaries and presets cannot prec expect(window).toEqual({ from: period.from, asOf: period.asOf }); expect(visitWindow(period, '7d')).toEqual(window); - expect(sumValidVisits(daily, window)).toBe(9); + expect(sumVisitTotals(daily, window)).toEqual({ validVisits: 9, invalidVisits: 2 }); expect(groupVisitDays(daily, window, 'day')).toEqual(daily); expect(period).toEqual(original); }); @@ -104,11 +118,48 @@ test('selected days are filtered before grouping and share the headline total wi invalidVisits: 2 } ]); - expect(sumValidVisits(daily, window)).toBe(9); + expect(sumVisitTotals(daily, window)).toEqual({ validVisits: 9, invalidVisits: 53 }); expect(weeks.reduce((total, week) => total + week.validVisits, 0)).toBe(9); expect(daily).toEqual(original); }); +test('window counts project both outcomes for each requested timeframe', () => { + const { period, daily } = dailyReport('2024-02-20T12:00:00.000Z', '2024-03-05T00:00:00.000Z', { + '2024-02-26': [100, 100], + '2024-02-27': [2, 50], + '2024-02-29': [3, 1], + '2024-03-04': [4, 2] + }); + const timeframes: VisitTimeframe[] = ['7d', 'all']; + const windows = timeframes.map((timeframe) => ({ + timeframe, + window: visitWindow(period, timeframe) + })); + + expect(countVisitWindows(daily, windows)).toEqual({ + '7d': { validVisits: 9, invalidVisits: 53 }, + all: { validVisits: 109, invalidVisits: 153 } + }); +}); + +test('missing daily reports stay absent while empty and zero-filled reports give window zeros', () => { + const { period, daily } = dailyReport('2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'); + const timeframes = availableVisitTimeframes(3); + const windows = timeframes.map((timeframe) => ({ + timeframe, + window: visitWindow(period, timeframe) + })); + const zeros = { + '7d': { validVisits: 0, invalidVisits: 0 }, + '30d': { validVisits: 0, invalidVisits: 0 }, + all: { validVisits: 0, invalidVisits: 0 } + }; + + expect(countVisitWindows(undefined, windows)).toEqual({}); + expect(countVisitWindows([], windows)).toEqual(zeros); + expect(countVisitWindows(daily, windows)).toEqual(zeros); +}); + test('days touching but outside the view are excluded from groups and totals', () => { const { daily } = dailyReport('2024-02-27T00:00:00.000Z', '2024-03-02T00:00:00.000Z', { '2024-02-27': [100, 100], @@ -118,7 +169,7 @@ test('days touching but outside the view are excluded from groups and totals', ( }); const window = { from: '2024-02-28T00:00:00.000Z', asOf: '2024-03-01T00:00:00.000Z' }; - expect(sumValidVisits(daily, window)).toBe(5); + expect(sumVisitTotals(daily, window)).toEqual({ validVisits: 5, invalidVisits: 3 }); expect(groupVisitDays(daily, window, 'day')).toEqual(daily.slice(1, 3)); }); @@ -239,7 +290,7 @@ test('exact calendar boundaries are complete and do not add a following bucket', invalidVisits: 0 } ]); - expect(sumValidVisits(daily, period)).toBe(0); - expect(sumValidVisits([], period)).toBe(0); + expect(sumVisitTotals(daily, period)).toEqual({ validVisits: 0, invalidVisits: 0 }); + expect(sumVisitTotals([], period)).toEqual({ validVisits: 0, invalidVisits: 0 }); expect(groupVisitDays([], period, 'month')).toEqual([]); }); diff --git a/packages/analytics/src/daily.ts b/packages/analytics/src/daily.ts index 16b0b81..d30f73c 100644 --- a/packages/analytics/src/daily.ts +++ b/packages/analytics/src/daily.ts @@ -1,6 +1,7 @@ -import type { ReportPeriod, VisitDetails } from './reports.js'; +import type { ReportPeriod, VisitDetails, VisitTotals } from './reports.js'; export type VisitTimeframe = '7d' | '30d' | '90d' | 'all'; +export type VisitCountsByTimeframe = Partial>; export type VisitWindow = { from: string; asOf: string }; export type VisitGranularity = 'day' | 'week' | 'month'; @@ -10,6 +11,16 @@ export function availableVisitTimeframes(months: 3 | 9 | 12): VisitTimeframe[] { return months === 3 ? ['7d', '30d', 'all'] : ['7d', '30d', '90d', 'all']; } +export function resolveVisitTimeframe( + available: VisitTimeframe[], + requested: string | null +): VisitTimeframe { + for (const timeframe of available) { + if (timeframe === requested) return timeframe; + } + return '30d'; +} + export function visitWindow(period: ReportPeriod, timeframe: VisitTimeframe): VisitWindow { if (timeframe === 'all') { return { from: period.from, asOf: period.asOf }; @@ -25,16 +36,30 @@ export function visitWindow(period: ReportPeriod, timeframe: VisitTimeframe): Vi }; } -export function sumValidVisits(daily: VisitDetails['daily'], window: VisitWindow): number { +export function sumVisitTotals(daily: VisitDetails['daily'], window: VisitWindow): VisitTotals { const from = Date.parse(window.from); const asOf = Date.parse(window.asOf); - let total = 0; + const totals: VisitTotals = { validVisits: 0, invalidVisits: 0 }; for (const day of daily) { if (intersectsWindow(day, from, asOf)) { - total += day.validVisits; + totals.validVisits += day.validVisits; + totals.invalidVisits += day.invalidVisits; } } - return total; + return totals; +} + +export function countVisitWindows( + daily: VisitDetails['daily'] | undefined, + windows: Array<{ timeframe: VisitTimeframe; window: VisitWindow }> +): VisitCountsByTimeframe { + const counts: VisitCountsByTimeframe = {}; + if (daily === undefined) return counts; + + for (const { timeframe, window } of windows) { + counts[timeframe] = sumVisitTotals(daily, window); + } + return counts; } export function visitGranularity(timeframe: VisitTimeframe, months: 3 | 9 | 12): VisitGranularity { diff --git a/packages/db/src/visit-reports/index.ts b/packages/db/src/visit-reports/index.ts index e6c3080..ff165f6 100644 --- a/packages/db/src/visit-reports/index.ts +++ b/packages/db/src/visit-reports/index.ts @@ -3,10 +3,11 @@ export { clearVisitReports, publishVisitReports, readOverviewVisitDetails, + readDomainVisitDetails, readVisitSummary, readVisitDetails } from './storage.js'; -export type { OverviewVisitDetails } from './storage.js'; +export type { OverviewVisitDetails, DomainVisitDetails } from './storage.js'; export type { AggregatedVisitReports, DailyVisitCounts, diff --git a/packages/db/src/visit-reports/storage.test.ts b/packages/db/src/visit-reports/storage.test.ts index f670f31..3f0282d 100644 --- a/packages/db/src/visit-reports/storage.test.ts +++ b/packages/db/src/visit-reports/storage.test.ts @@ -17,6 +17,7 @@ import { publishVisitReports, readVisitDetails, readOverviewVisitDetails, + readDomainVisitDetails, readVisitSummary } from './storage.js'; @@ -177,6 +178,115 @@ test('overview details reject an invalid overview independently of summary JSON' } }); +test('domain details use one SQL snapshot with current paths and root, without summaries', async () => { + const current = preparedReport(domainScope, asOf, { validVisits: 3, invalidVisits: 1 }); + const root = preparedReport(rootScope); + const docs = preparedReport({ kind: 'link', domain, path: '/docs' }); + const removed = preparedReport({ kind: 'link', domain, path: '/removed' }); + const otherDomain = preparedReport({ kind: 'domain', domain: 'other.example.test' }); + const otherLink = preparedReport({ kind: 'link', domain: 'other.example.test', path: '/docs' }); + await publishVisitReports( + db, + encodeVisitReports([ + preparedReport(overviewScope), + current, + root, + docs, + removed, + otherDomain, + otherLink + ]) + ); + await db.updateTable('analytics_reports').set({ summary_json: '{}' }).execute(); + await db + .updateTable('analytics_reports') + .set({ details_json: '{}' }) + .where(({ or, eb }) => + or([ + eb('scope_kind', '=', 'overview'), + eb('domain', '=', 'other.example.test'), + eb('path', '=', '/removed') + ]) + ) + .execute(); + const statements = vi.spyOn(DatabaseSync.prototype, 'prepare'); + try { + expect(await readDomainVisitDetails(db, domain, ['/docs', '/docs', '/new'])).toEqual({ + domain: current.details, + links: [ + { path: '/', details: root.details }, + { path: '/docs', details: docs.details } + ] + }); + expect(statements).toHaveBeenCalledTimes(1); + const query = statements.mock.calls[0]?.[0]; + expect(query).toMatch( + /^select\s+"scope_kind",\s*"path",\s*"details_json"\s+from\s+"analytics_reports"/i + ); + expect(query).not.toMatch(/summary_json|\bvisits\b|\bjoin\b/i); + } finally { + statements.mockRestore(); + } +}); + +test('domain details require a domain report and preserve missing paths', async () => { + expect(await readDomainVisitDetails(db, domain, ['/docs'])).toBeNull(); + await publishVisitReports(db, encodeVisitReports([preparedReport(rootScope)])); + expect(await readDomainVisitDetails(db, domain, [])).toBeNull(); + const current = preparedReport(domainScope); + await publishVisitReports(db, encodeVisitReports([current])); + expect(await readDomainVisitDetails(db, domain, ['/docs'])).toEqual({ + domain: current.details, + links: [] + }); +}); + +test('domain details validate the requested domain independently of summary JSON', async () => { + const current = preparedReport(domainScope); + await publishVisitReports(db, encodeVisitReports([current])); + await db + .updateTable('analytics_reports') + .set({ + summary_json: '{}', + details_json: JSON.stringify({ + ...current.details, + scope: { kind: 'domain', domain: 'wrong.example.test' } + }) + }) + .execute(); + await expect(readDomainVisitDetails(db, domain, [])).rejects.toThrow( + /^Invalid analytics report$/ + ); +}); + +test('domain details validate each selected path scope', async () => { + const root = preparedReport(rootScope); + await publishVisitReports(db, encodeVisitReports([preparedReport(domainScope), root])); + await db + .updateTable('analytics_reports') + .set({ details_json: JSON.stringify({ ...root.details, scope: domainScope }) }) + .where('scope_kind', '=', 'link') + .execute(); + await expect(readDomainVisitDetails(db, domain, [])).rejects.toThrow( + /^Invalid analytics report$/ + ); +}); + +test('domain details require selected paths to share the domain publication period', async () => { + await publishVisitReports( + db, + encodeVisitReports([preparedReport(domainScope), preparedReport(rootScope)]) + ); + await db + .updateTable('analytics_reports') + .set({ details_json: JSON.stringify(preparedReport(rootScope, asOf + 86_400_000).details) }) + .where('scope_kind', '=', 'link') + .execute(); + await expect(readDomainVisitDetails(db, domain, [])).rejects.toThrow( + /^Invalid analytics report$/ + ); +}); + test('publication replaces the complete batch, including when the replacement is empty', async () => { await publishVisitReports( db, diff --git a/packages/db/src/visit-reports/storage.ts b/packages/db/src/visit-reports/storage.ts index 050e1de..29928c2 100644 --- a/packages/db/src/visit-reports/storage.ts +++ b/packages/db/src/visit-reports/storage.ts @@ -76,6 +76,47 @@ export async function readOverviewVisitDetails( return { overview, domains }; } +export interface DomainVisitDetails { + domain: VisitDetails; + links: Array<{ path: string; details: VisitDetails }>; +} + +export async function readDomainVisitDetails( + db: Kysely, + domain: string, + currentPaths: string[] +): Promise { + const paths = [...new Set(['/', ...currentPaths])]; + const rows = await db + .selectFrom('analytics_reports') + .select(['scope_kind', 'path', 'details_json']) + .where('domain', '=', domain) + .where(({ and, or, eb }) => + or([ + and([eb('scope_kind', '=', 'domain'), eb('path', 'is', null)]), + and([eb('scope_kind', '=', 'link'), eb('path', 'in', paths)]) + ]) + ) + .execute(); + const domainRow = rows.find((row) => row.scope_kind === 'domain'); + if (!domainRow) return null; + const details = parseVisitDetails(domainRow.details_json, { kind: 'domain', domain }); + const links: DomainVisitDetails['links'] = []; + for (const row of rows) { + if (row.scope_kind === 'domain') continue; + if (row.scope_kind !== 'link' || row.path === null) { + throw new Error('Invalid analytics report'); + } + const linkDetails = parseVisitDetails( + row.details_json, + { kind: 'link', domain, path: row.path }, + details.period + ); + links.push({ path: row.path, details: linkDetails }); + } + return { domain: details, links }; +} + export async function readVisitSummary( db: Kysely, scope: ReportScope -- 2.54.0 From 6f7237bb481ae78c3e74f288ea2df5da161b612b Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 10 Nov 2026 17:25:03 -0800 Subject: [PATCH 2/2] fix(admin): refine responsive dashboards --- .changeset/domain-visits-chart.md | 2 +- apps/web/e2e/auth.e2e.ts | 104 ++++++++---- apps/web/package.json | 1 + .../src/lib/components/admin/AdminNav.svelte | 5 +- .../src/lib/components/admin/AdminPage.svelte | 30 ++-- .../lib/components/admin/LinkInventory.svelte | 53 ++++++ .../components/admin/VisitTimeframes.svelte | 154 +++++++++++++++--- .../src/lib/components/admin/format.test.ts | 4 +- apps/web/src/lib/components/admin/format.ts | 11 ++ apps/web/src/lib/styles/admin.css | 154 +++++++++++++----- .../routes/(admin)/(auth)/admin/+page.svelte | 59 ++++--- .../admin/domains/[domain]/+page.svelte | 70 +++----- .../[domain]/links/[...path]/+page.svelte | 10 +- pnpm-lock.yaml | 120 ++++++++++++++ pnpm-workspace.yaml | 1 + 15 files changed, 588 insertions(+), 190 deletions(-) create mode 100644 apps/web/src/lib/components/admin/LinkInventory.svelte diff --git a/.changeset/domain-visits-chart.md b/.changeset/domain-visits-chart.md index b1db52c..85de78e 100644 --- a/.changeset/domain-visits-chart.md +++ b/.changeset/domain-visits-chart.md @@ -4,4 +4,4 @@ '@goto/cli': minor --- -Add the visits chart and retention-aware timeframes to domain dashboards. Reuse the overview chart, timeframe control and window-count helpers so domain traffic cards, base URL and current-link counts follow one selected window. Preserve the selected timeframe between overview and domain pages, keep link series out of browser data, and show inventory only when analytics is disabled. Individual link pages remain unchanged. +Add the visits chart and retention-aware timeframes to domain dashboards. Reuse the overview chart, timeframe control and window-count helpers so domain traffic cards, base URL and current-link counts follow one selected window. Preserve the selected timeframe between overview and domain pages, keep link series out of browser data, and show inventory only when analytics is disabled. Use contextual page headers, compact mobile timeframe dropdowns and responsive inventory rows that fit without horizontal scrolling. Each inventory row is a single link to its statistics page, with plain-text details. Individual-link analytics remain full-retention summaries. diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 661d8f0..3200544 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -46,8 +46,9 @@ test('shared credentials protect overview, domain and link pages and sign-out re await expect(graph).toBeVisible(); await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 2\./); const domainLink = page.getByRole('link', { name: 'go.example.org', exact: true }); - const domainRow = page.getByRole('row').filter({ has: domainLink }); - await expect(domainRow.getByRole('cell')).toHaveText(['6', '1']); + const domainRow = page.getByRole('listitem').filter({ has: domainLink }); + const domainCounts = domainRow.locator('.admin-inventory-number'); + await expect(domainCounts).toHaveText(['6', '1']); const selectionRequests: string[] = []; const recordSelectionRequest = (request: Request) => { const type = request.resourceType(); @@ -63,7 +64,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re 'true' ); await expect(visitsValue).toHaveText('1'); - await expect(domainRow.getByRole('cell')).toHaveText(['6', '0']); + await expect(domainCounts).toHaveText(['6', '0']); await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 1\./); await timeframe.getByRole('button', { name: '3 months', exact: true }).click(); await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( @@ -71,7 +72,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re 'true' ); await expect(visitsValue).toHaveText('3'); - await expect(domainRow.getByRole('cell')).toHaveText(['6', '2']); + await expect(domainCounts).toHaveText(['6', '2']); await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 3\./); await page.goBack(); await expect(timeframe.getByRole('button', { name: '7 days', exact: true })).toHaveAttribute( @@ -79,7 +80,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re 'true' ); await expect(visitsValue).toHaveText('1'); - await expect(domainRow.getByRole('cell')).toHaveText(['6', '0']); + await expect(domainCounts).toHaveText(['6', '0']); await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 1\./); await page.goForward(); await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( @@ -87,7 +88,7 @@ test('shared credentials protect overview, domain and link pages and sign-out re 'true' ); await expect(visitsValue).toHaveText('3'); - await expect(domainRow.getByRole('cell')).toHaveText(['6', '2']); + await expect(domainCounts).toHaveText(['6', '2']); await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 3\./); page.off('request', recordSelectionRequest); expect(selectionRequests).toEqual([]); @@ -100,11 +101,15 @@ test('shared credentials protect overview, domain and link pages and sign-out re await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 3\./); expect(navigationWarnings).toEqual([]); await domainLink.click(); - await expect(page.getByRole('heading', { name: 'go.example.org', exact: true })).toBeVisible(); - const overviewBreadcrumb = page - .getByRole('navigation', { name: 'Breadcrumb', exact: true }) - .getByRole('link', { name: 'Overview', exact: true }); - await expect(overviewBreadcrumb).toHaveAttribute('href', '/admin?period=all'); + await expect(page.getByRole('heading', { name: 'Domain', exact: true })).toBeVisible(); + const context = page.locator('.admin-context'); + await expect(context).toHaveText('go.example.org'); + await expect(page).toHaveTitle('Domain — go.example.org — Goto'); + const overviewLink = page + .getByRole('navigation', { name: 'Administration', exact: true }) + .getByRole('link'); + await expect(overviewLink).toHaveAttribute('href', '/admin?period=all'); + await expect(page.getByRole('link', { name: 'Overview', exact: true })).toHaveCount(0); await expect(trend).toHaveAttribute('aria-busy', 'false'); await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 2\./); await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( @@ -121,31 +126,55 @@ test('shared credentials protect overview, domain and link pages and sign-out re await expect( page.getByRole('region', { name: 'Invalid clicks', exact: true }).locator('.admin-stat-value') ).toHaveText('1'); - const linksTable = page.getByRole('region', { name: 'Links on go.example.org', exact: true }); - const baseTable = page.getByRole('region', { name: 'Base URL on go.example.org', exact: true }); + const linksInventory = page.getByRole('region', { name: 'Links on go.example.org', exact: true }); + const baseInventory = page.getByRole('region', { + name: 'Base URL on go.example.org', + exact: true + }); const columns = ['Path', 'Destination', 'State', 'Visits']; - await expect(linksTable.getByRole('columnheader')).toHaveText(columns); - const retainedLink = linksTable - .getByRole('row') + await expect(linksInventory.locator('.admin-inventory-heading > span')).toHaveText(columns); + const retainedLink = linksInventory + .getByRole('listitem') .filter({ has: page.getByRole('link', { name: '/a%2Fb', exact: true }) }); - await expect(retainedLink.getByRole('cell').last()).toHaveText('1'); - await timeframe.getByRole('button', { name: '30 days', exact: true }).click(); + await expect(retainedLink.getByRole('link')).toHaveCount(1); + const retainedVisits = retainedLink.locator('.admin-inventory-number'); + await expect(retainedVisits).toHaveText('1'); + await page.setViewportSize({ width: 390, height: 844 }); + const mobileTimeframe = page.getByRole('button', { name: /^Timeframe: / }); + await expect(mobileTimeframe).toContainText('3 months'); + await mobileTimeframe.click(); + await page + .getByRole('listbox', { name: 'Timeframe', exact: true }) + .getByRole('option', { name: '30 days', exact: true }) + .click(); await expect(visitsValue).toHaveText('1'); - await expect(retainedLink.getByRole('cell').last()).toHaveText('0'); + await expect(retainedVisits).toHaveText('0'); await expect(graph).toHaveAccessibleName(/^Valid visits by day\. Total: 1\./); + 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(retainedLink.getByRole('cell').last()).toHaveText('1'); + await expect(retainedVisits).toHaveText('1'); await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 2\./); - const historicalInvalid = linksTable - .getByRole('row') + const historicalInvalid = linksInventory + .getByRole('listitem') .filter({ has: page.getByRole('link', { name: '/a/b', exact: true }) }); - await expect(historicalInvalid.getByRole('cell').last()).toHaveText('0'); - await expect(baseTable.getByRole('columnheader')).toHaveText(columns); - await expect(baseTable.getByRole('cell')).toHaveText(['404 Not found', 'Fallback', '0']); + await expect(historicalInvalid.locator('.admin-inventory-number')).toHaveText('0'); + await expect(baseInventory.locator('.admin-inventory-heading > span')).toHaveText(columns); await expect( - linksTable.getByRole('link', { name: 'https://example.org/first-domain', exact: true }) + baseInventory.locator( + '.admin-inventory-secondary, .admin-inventory-state, .admin-inventory-number' + ) + ).toHaveText(['404 Not found', 'Fallback', '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(baseInventory.getByRole('link')).toHaveCount(1); const domainBreadcrumb = page .getByRole('navigation', { name: 'Breadcrumb', exact: true }) @@ -153,7 +182,9 @@ test('shared credentials protect overview, domain and link pages and sign-out re const docsPath = '/admin/domains/go.example.org/links/docs'; await page.getByRole('link', { name: '/docs', exact: true }).click(); await expect(page).toHaveURL(`${appServer.origin}${docsPath}`); - await expect(page.getByRole('heading', { name: '/docs', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible(); + await expect(context).toHaveText('go.example.org /docs'); + await expect(page).toHaveTitle('Link — go.example.org/docs — Goto'); await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(0); await expect(visitsValue).toHaveText('1'); await expect( @@ -162,19 +193,22 @@ test('shared credentials protect overview, domain and link pages and sign-out re await domainBreadcrumb.click(); await page.getByRole('link', { name: '/a%2Fb', exact: true }).click(); - await expect(page.getByRole('heading', { name: '/a%2Fb', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible(); + await expect(context).toHaveText('go.example.org /a%2Fb'); await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links/a%252Fb`); await expect(visitsValue).toHaveText('1'); await domainBreadcrumb.click(); await page.getByRole('link', { name: '/a/b', exact: true }).click(); await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links/a/b`); - await expect(page.getByRole('heading', { name: '/a/b', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible(); + await expect(context).toHaveText('go.example.org /a/b'); await expect(visitsValue).toHaveText('0'); await domainBreadcrumb.click(); - await baseTable.getByRole('link', { name: '/', exact: true }).click(); + await baseInventory.getByRole('link', { name: '/', exact: true }).click(); await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links`); - await expect(page.getByRole('heading', { name: '/', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible(); + await expect(context).toHaveText('go.example.org /'); await expect(visitsValue).toHaveText('0'); await domainBreadcrumb.click(); await expect(timeframe.getByRole('button', { name: '30 days', exact: true })).toHaveAttribute( @@ -210,7 +244,9 @@ test.describe('analytics disabled', () => { 0 ); await expect( - page.getByRole('region', { name: 'Domain inventory', exact: true }).getByRole('columnheader') + page + .getByRole('region', { name: 'Domain inventory', exact: true }) + .locator('.admin-inventory-heading > span') ).toHaveText(['Domain', 'Links']); await expect(page.getByRole('status')).toHaveText('Analytics disabled.'); await page.getByRole('link', { name: 'go.example.org', exact: true }).click(); @@ -222,12 +258,12 @@ test.describe('analytics disabled', () => { await expect( page .getByRole('region', { name: 'Links on go.example.org', exact: true }) - .getByRole('columnheader') + .locator('.admin-inventory-heading > span') ).toHaveText(['Path', 'Destination', 'State']); await expect( page .getByRole('region', { name: 'Base URL on go.example.org', exact: true }) - .getByRole('columnheader') + .locator('.admin-inventory-heading > span') ).toHaveText(['Path', 'Destination', 'State']); }); }); diff --git a/apps/web/package.json b/apps/web/package.json index 85f8a10..1c04d85 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@goto/config": "workspace:*", "@goto/db": "workspace:*", "better-auth": "catalog:", + "bits-ui": "catalog:", "chart.js": "catalog:", "piscina": "catalog:" }, diff --git a/apps/web/src/lib/components/admin/AdminNav.svelte b/apps/web/src/lib/components/admin/AdminNav.svelte index a27a8c3..15824fb 100644 --- a/apps/web/src/lib/components/admin/AdminNav.svelte +++ b/apps/web/src/lib/components/admin/AdminNav.svelte @@ -4,7 +4,7 @@ import { authClient } from '#lib/auth-client.js'; import ThemeToggle from '../ThemeToggle.svelte'; - let { title }: { title: string } = $props(); + let { title, overviewHref }: { title: string; overviewHref: string } = $props(); let currentPath = $derived(page.url.pathname + page.url.search); let pending = $state(false); @@ -49,7 +49,8 @@
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 565c6ff..4726e53 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 @@ -6,7 +6,8 @@ import ReportNotice from '#lib/components/admin/ReportNotice.svelte'; import VisitsChart from '#lib/components/admin/VisitsChart.svelte'; import VisitTimeframes from '#lib/components/admin/VisitTimeframes.svelte'; - import { formatCount, formatRetention } from '#lib/components/admin/format.js'; + import LinkInventory from '#lib/components/admin/LinkInventory.svelte'; + import { formatRetention } from '#lib/components/admin/format.js'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -20,14 +21,17 @@ ); let validVisits = $derived(data.countsByTimeframe[timeframe]?.validVisits ?? null); let invalidClicks = $derived(data.invalidClicksByTimeframe[timeframe] ?? null); + let baseLinks = $derived([{ path: '/', ...data.base }]); + {#snippet context()}{data.domain}{/snippet} {#snippet actions()} {#if data.available && data.timeframes.length > 0}

Base URL

-
- - {#if data.collectionEnabled}{/if} - - - - - - {#if data.collectionEnabled}{/if} - - - -
+

Links

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

No links configured.

{:else} -
- - {#if data.collectionEnabled}{/if} - - {#each data.links as link (link.path)} - - - - - {#if data.collectionEnabled}{/if} - - {/each} - - -
+ {/if}
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 a5050fd..db9acf4 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 @@ -9,11 +9,15 @@ + {#snippet context()} + + {/snippet}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d855e8a..1bcbdf0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ catalogs: better-auth: specifier: 1.7.14 version: 1.7.14 + bits-ui: + specifier: 2.18.1 + version: 2.18.1 chart.js: specifier: 4.6.1 version: 4.6.1 @@ -187,6 +190,9 @@ importers: better-auth: specifier: 'catalog:' version: 1.7.14(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vitest@4.1.7) + bits-ui: + specifier: 'catalog:' + version: 2.18.1(@internationalized/date@3.12.4)(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0)) chart.js: specifier: 'catalog:' version: 4.6.1 @@ -718,6 +724,15 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -747,6 +762,9 @@ packages: '@iconify/utils@3.1.7': resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1066,6 +1084,9 @@ packages: svelte: ^5.46.4 vite: 8.0.16 + '@swc/helpers@0.5.24': + resolution: {integrity: sha512-6tcXo4kA8HgGO7AVmawB57RYi/piBPYVHVcO6dXjX+SZw8GoHmi262sp4v2Ha2kNP9Cx84qei27yT+fjS1lxgg==} + '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -1283,6 +1304,13 @@ packages: zod: optional: true + bits-ui@2.18.1: + resolution: {integrity: sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + brace-expansion@5.0.12: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} @@ -1352,6 +1380,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1556,6 +1588,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1717,6 +1752,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1895,6 +1934,15 @@ packages: rou3@0.9.2: resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + runed@0.35.1: + resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} + peerDependencies: + '@sveltejs/kit': ^2.21.0 + svelte: ^5.7.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -1939,6 +1987,9 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1960,10 +2011,19 @@ packages: svelte: optional: true + svelte-toolbelt@0.10.7: + resolution: {integrity: sha512-gjuUVEEpo38UJCeI1ssE3NxG31tYENczPTcTGTXf4338qAQNdR9I1oAEbQY0KQFmbdUojNIhHuRNix7ThwFNxQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + svelte@5.57.3: resolution: {integrity: sha512-5mAftM3SoDV9X0zdF386F9p2pv8iKOwi/KfBS1XnBAkVCKFzVOLHUoa8iGWAt4buoicLmyWVRpuuD5wplXOQsg==} engines: {node: '>=18'} + tabbable@6.5.1: + resolution: {integrity: sha512-dMCuwDUY6Noz3zVKLefBC4wq+aJV20yc7vWmM7uTNGwtQSFF5kX7OUIUsxuVajz/bdt5Xqdah3/0NLjth+cM5A==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2495,6 +2555,17 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2523,6 +2594,10 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.24 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -2744,6 +2819,8 @@ snapshots: vite: 8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1) vitefu: 1.1.3(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)) + '@swc/helpers@0.5.24': {} + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 @@ -2974,6 +3051,19 @@ snapshots: optionalDependencies: zod: 4.6.7 + bits-ui@2.18.1(@internationalized/date@3.12.4)(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0)): + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/dom': 1.8.0 + '@internationalized/date': 3.12.4 + esm-env: 1.2.2 + runed: 0.35.1(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0)) + svelte: 5.57.3(@typescript-eslint/types@8.60.0) + svelte-toolbelt: 0.10.7(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0)) + tabbable: 6.5.1 + transitivePeerDependencies: + - '@sveltejs/kit' + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -3022,6 +3112,8 @@ snapshots: defu@6.1.7: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} devalue@5.9.4: {} @@ -3240,6 +3332,8 @@ snapshots: imurmurhash@0.1.4: {} + inline-style-parser@0.2.7: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -3364,6 +3458,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -3528,6 +3624,15 @@ snapshots: rou3@0.9.2: {} + runed@0.35.1(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0)): + dependencies: + dequal: 2.0.3 + esm-env: 1.2.2 + lz-string: 1.5.0 + svelte: 5.57.3(@typescript-eslint/types@8.60.0) + optionalDependencies: + '@sveltejs/kit': 3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)) + sade@1.8.1: dependencies: mri: 1.2.0 @@ -3560,6 +3665,10 @@ snapshots: std-env@4.3.0: {} + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3589,6 +3698,15 @@ snapshots: optionalDependencies: svelte: 5.57.3(@typescript-eslint/types@8.60.0) + svelte-toolbelt@0.10.7(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0)): + dependencies: + clsx: 2.1.1 + runed: 0.35.1(@sveltejs/kit@3.1.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.57.3(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.57.3(@typescript-eslint/types@8.60.0)) + style-to-object: 1.0.14 + svelte: 5.57.3(@typescript-eslint/types@8.60.0) + transitivePeerDependencies: + - '@sveltejs/kit' + svelte@5.57.3(@typescript-eslint/types@8.60.0): dependencies: '@jridgewell/remapping': 2.3.5 @@ -3609,6 +3727,8 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' + tabbable@6.5.1: {} + tinybench@2.9.0: {} tinyexec@1.3.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40e04bb..4a68457 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,6 +15,7 @@ catalog: '@types/node': 24.12.4 '@vitest/coverage-v8': 4.1.7 better-auth: 1.7.14 + bits-ui: 2.18.1 chart.js: 4.6.1 esbuild: 0.28.2 eslint: 10.4.1 -- 2.54.0