diff --git a/.changeset/overview-visits-chart.md b/.changeset/overview-visits-chart.md new file mode 100644 index 0000000..b8c06d3 --- /dev/null +++ b/.changeset/overview-visits-chart.md @@ -0,0 +1,7 @@ +--- +'@goto/web': minor +'@goto/worker': minor +'@goto/cli': minor +--- + +Add an overview visits chart and retention-aware page-wide timeframes using prepared daily analytics. Headline and domain visit counts follow the selected UTC calendar window, with automatic daily, Monday-week or calendar-month chart grouping. Keep domain series out of the browser payload and hide analytics cards, controls and columns when collection is disabled. Domain and link pages remain unchanged. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 3ef7afc..17feca9 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -3,27 +3,29 @@ FROM node:26.5.0-alpine AS base WORKDIR /m ENV CI=true \ - HOME=/home/app \ - PNPM_HOME=/home/app/.pnpm \ - XDG_DATA_HOME=/home/app/.local/share \ - XDG_CACHE_HOME=/home/app/.cache \ - XDG_CONFIG_HOME=/home/app/.config + HOME=/root RUN npm install -g pnpm@11.9.0 \ - && mkdir -p /home/app/store \ - && printf 'store-dir=/home/app/store\n' > /home/app/.npmrc + && mkdir -p /pnpm/store \ + && printf 'store-dir=/pnpm/store\n' > /root/.npmrc FROM base AS dev ARG LOCAL_UID=1000 ARG LOCAL_GID=1000 COPY pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --frozen-lockfile COPY . . -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile +ENV HOME=/home/app \ + PNPM_HOME=/home/app/.pnpm \ + XDG_DATA_HOME=/home/app/.local/share \ + XDG_CACHE_HOME=/home/app/.cache \ + XDG_CONFIG_HOME=/home/app/.config # Only generated output and caches are writable; source binds stay read-only. -RUN mkdir -p /m/apps/web/config /m/apps/web/data /m/packages/config/dist /m/packages/analytics/dist /m/packages/db/dist /m/apps/web/.svelte-kit \ +RUN mkdir -p /home/app/store /m/apps/web/config /m/apps/web/data /m/packages/config/dist /m/packages/analytics/dist /m/packages/db/dist /m/apps/web/.svelte-kit \ '/m/apps/web/node_modules/$app' \ /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp \ + && printf 'store-dir=/home/app/store\n' > /home/app/.npmrc \ && chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/apps/web/data /m/packages/config/dist /m/packages/analytics/dist /m/packages/db/dist \ /m/apps/web/.svelte-kit '/m/apps/web/node_modules/$app' \ /m/apps/web/node_modules/.vite /m/apps/web/node_modules/.vite-temp @@ -35,14 +37,14 @@ CMD ["sh", "-c", "pnpm -r --filter @goto/config --filter @goto/db... build && ex FROM base AS builder COPY pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --frozen-lockfile COPY . . -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile RUN pnpm --filter "@goto/web..." build FROM builder AS pruner -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store \ +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ pnpm --config.hoist-workspace-packages=false --filter @goto/web --prod deploy --legacy /pruned \ && mkdir -p /pruned/node_modules diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index 11bf44a..0707ea6 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -18,7 +18,7 @@ services: NODE_ENV: development PORT: 5173 ports: - - '127.0.0.1:5173:5173' + - '127.0.0.1:${GOTO_DEV_PORT:-5173}:5173' volumes: - ./config:/m/apps/web/config:ro - ./data:/m/apps/web/data diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index e4f7001..835612b 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -1,3 +1,4 @@ +import type { Request } from '@playwright/test'; import { expect, test } from './support/test.ts'; test.use({ withWorker: true }); @@ -6,6 +7,11 @@ test('shared credentials protect overview, domain and link pages and sign-out re page, appServer }) => { + const navigationWarnings: string[] = []; + page.on('console', (message) => { + const text = message.text(); + if (text.includes('push_state_deprecated')) navigationWarnings.push(text); + }); await page.goto(`${appServer.origin}/admin`); await expect(page).toHaveURL(/\/signin\?/); await page.getByLabel('Username', { exact: true }).fill('example'); @@ -20,17 +26,82 @@ test('shared credentials protect overview, domain and link pages and sign-out re .getByRole('region', { name: 'Valid visits', exact: true }) .locator('.admin-stat-value'); await expect(page.getByRole('region', { name: 'Domains', exact: true })).toContainText('2'); - await expect(visitsValue).toHaveText('3'); + await expect(visitsValue).toHaveText('2'); const retentionValue = page .getByRole('region', { name: 'Retention', exact: true }) .locator('.admin-stat-value'); await expect(retentionValue).toHaveText('3 months'); await expect(page.locator('.admin-cards').getByRole('region')).toHaveCount(4); + const heading = page.locator('.admin-heading'); + await expect(heading.locator('.admin-title > h1 + time')).toBeVisible(); + const timeframe = heading.getByRole('group', { name: 'Timeframe', exact: true }); + await expect(timeframe.getByRole('button')).toHaveText(['7 days', '30 days', '3 months']); + await expect(timeframe.getByRole('button', { name: '30 days', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + const trend = page.getByRole('region', { name: 'Visits over time', exact: true }); + await expect(trend).toHaveAttribute('aria-busy', 'false'); + const graph = trend.getByRole('img'); + 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 selectionRequests: string[] = []; + const recordSelectionRequest = (request: Request) => { + const type = request.resourceType(); + if (type === 'document' || type === 'fetch' || type === 'xhr') { + selectionRequests.push(request.url()); + } + }; + page.on('request', recordSelectionRequest); + await timeframe.getByRole('button', { name: '7 days', exact: true }).click(); + await expect(page).toHaveURL(`${appServer.origin}/admin?period=7d`); + await expect(timeframe.getByRole('button', { name: '7 days', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(visitsValue).toHaveText('1'); + await expect(domainRow.getByRole('cell')).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( + 'aria-pressed', + 'true' + ); + await expect(visitsValue).toHaveText('3'); await expect(domainRow.getByRole('cell')).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( + 'aria-pressed', + 'true' + ); + await expect(visitsValue).toHaveText('1'); + await expect(domainRow.getByRole('cell')).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( + 'aria-pressed', + 'true' + ); + await expect(visitsValue).toHaveText('3'); + await expect(domainRow.getByRole('cell')).toHaveText(['6', '2']); + await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 3\./); + page.off('request', recordSelectionRequest); + expect(selectionRequests).toEqual([]); + await page.reload(); + await expect(timeframe.getByRole('button', { name: '3 months', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(visitsValue).toHaveText('3'); + 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(); + await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(0); await expect(visitsValue).toHaveText('2'); await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([ 'Links', @@ -94,3 +165,27 @@ test('shared credentials protect overview, domain and link pages and sign-out re await expect(page).toHaveURL(/\/signin\?/); await expect(page.getByLabel('Username', { exact: true })).toBeVisible(); }); + +test.describe('analytics disabled', () => { + test.use({ withWorker: false, retentionMonths: 0 }); + + test('overview remains inventory-only without analytics controls or columns', async ({ + page, + appServer + }) => { + await page.goto(`${appServer.origin}/admin`); + await page.getByLabel('Username', { exact: true }).fill('example'); + await page.getByLabel('Password', { exact: true }).fill('e2e-only-password'); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Overview', exact: true })).toBeVisible(); + await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText(['Domains', '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: 'Domain inventory', exact: true }).getByRole('columnheader') + ).toHaveText(['Domain', 'Links']); + await expect(page.getByRole('status')).toHaveText('Analytics disabled.'); + }); +}); diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.mjs index 51c23a4..c4ad354 100644 --- a/apps/web/e2e/server.mjs +++ b/apps/web/e2e/server.mjs @@ -26,6 +26,7 @@ process.env.PORT = String(address.port); const configuration = { site: { url: `http://localhost:${address.port}` }, auth: { credentials: { username: 'example', password: 'e2e-only-password' } }, + analytics: { retention_months: Number(process.env.GOTO_E2E_RETENTION_MONTHS ?? '3') }, domains: { 'go.example.org': { links: { @@ -60,10 +61,20 @@ async function seedVisits() { os: 'Other' }; const visits = [ - { domain: 'go.example.org', path: '/docs', outcome: 'valid' }, + { + domain: 'go.example.org', + path: '/docs', + outcome: 'valid', + visited_at: Date.now() - 14 * 86_400_000 + }, { domain: 'go.example.org', path: '/a/b', outcome: 'invalid' }, { domain: 'go.example.org', path: '/', outcome: 'invalid' }, - { domain: 'go.example.org', path: '/a%2Fb', outcome: 'valid' }, + { + domain: 'go.example.org', + path: '/a%2Fb', + outcome: 'valid', + visited_at: Date.now() - 45 * 86_400_000 + }, { domain: 'tiny.example.org', path: '/docs', outcome: 'valid' } ]; for (const visit of visits) await insertVisit(db, { ...common, ...visit }); diff --git a/apps/web/e2e/support/test.ts b/apps/web/e2e/support/test.ts index 7546b55..7483354 100644 --- a/apps/web/e2e/support/test.ts +++ b/apps/web/e2e/support/test.ts @@ -53,16 +53,22 @@ async function stopServer(child: ChildProcess): Promise { } } -export const test = base.extend<{ appServer: AppServer; withWorker: boolean }>({ +export const test = base.extend<{ + appServer: AppServer; + withWorker: boolean; + retentionMonths: 0 | 3 | 9 | 12; +}>({ withWorker: [false, { option: true }], - appServer: async ({ withWorker }, use, testInfo) => { + retentionMonths: [3, { option: true }], + appServer: async ({ withWorker, retentionMonths }, use, testInfo) => { const directory = process.env.GOTO_E2E_BUILD_DIR; if (!directory) throw new Error('Missing E2E build directory'); const child = fork(new URL('../server.mjs', import.meta.url), [], { env: { ...serverEnvironment(), GOTO_E2E_BUILD_DIR: directory, - GOTO_E2E_WORKER: withWorker ? '1' : '0' + GOTO_E2E_WORKER: withWorker ? '1' : '0', + GOTO_E2E_RETENTION_MONTHS: String(retentionMonths) }, execArgv: [], stdio: ['ignore', 'pipe', 'pipe', 'ipc'] diff --git a/apps/web/package.json b/apps/web/package.json index 4c59310..87205d5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@goto/config": "workspace:*", "@goto/db": "workspace:*", "better-auth": "catalog:", + "chart.js": "catalog:", "piscina": "catalog:" }, "devDependencies": { diff --git a/apps/web/src/lib/components/admin/AdminPage.svelte b/apps/web/src/lib/components/admin/AdminPage.svelte index 184b496..a0d8496 100644 --- a/apps/web/src/lib/components/admin/AdminPage.svelte +++ b/apps/web/src/lib/components/admin/AdminPage.svelte @@ -10,6 +10,7 @@ updatedAt = null, backHref = '', backLabel = 'Overview', + actions, children }: { siteTitle: string; @@ -17,6 +18,7 @@ updatedAt?: number | null; backHref?: string; backLabel?: string; + actions?: Snippet; children: Snippet; } = $props(); @@ -27,12 +29,15 @@
{#if backHref}{/if}
-

{title}

- {#if updatedAt !== null} - - {/if} +
+

{title}

+ {#if updatedAt !== null} + + {/if} +
+ {@render actions?.()}
{@render children()} diff --git a/apps/web/src/lib/components/admin/VisitsChart.svelte b/apps/web/src/lib/components/admin/VisitsChart.svelte new file mode 100644 index 0000000..4c7b435 --- /dev/null +++ b/apps/web/src/lib/components/admin/VisitsChart.svelte @@ -0,0 +1,253 @@ + + +
+
+

Visits

+ {groupingLabels[granularity]} +
+ + {#if unavailable} +

Chart unavailable.

+ {:else} + + {/if} +
+ + diff --git a/apps/web/src/lib/server/visit-reports.test.ts b/apps/web/src/lib/server/visit-reports.test.ts index 4f284e1..da64526 100644 --- a/apps/web/src/lib/server/visit-reports.test.ts +++ b/apps/web/src/lib/server/visit-reports.test.ts @@ -1,6 +1,7 @@ import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { createVisitPeriod, encodeVisitReports, @@ -12,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 { readVisitSummary } from './visit-reports.js'; +import { readOverviewDetails, 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-')); @@ -46,22 +47,95 @@ test('reads prepared output and hides snapshots from a different retention polic browsers: [{ family: 'Other', ...totals }], operatingSystems: [{ family: 'Other', ...totals }] }; + const domain = 'go.example.test'; + const domainScope = { kind: 'domain', domain } as const; + const domainDetails: VisitDetails = { ...details, scope: domainScope }; try { const db = await openDb(configuration.database.path); try { await runMigrations(db); - await publishVisitReports(db, encodeVisitReports([{ summary, details }])); + expect(await readOverviewDetails(configuration, [domain])).toBeNull(); + await publishVisitReports( + db, + encodeVisitReports([ + { summary, details }, + { + summary: { + version: 1, + scope: domainScope, + period, + totals, + base: { validVisits: 0, invalidVisits: 0 }, + links: [] + }, + details: domainDetails + } + ]) + ); } finally { await db.destroy(); } expect(await readVisitSummary(configuration, { kind: 'overview' })).toEqual(summary); - configuration.analytics.retention_months = 9; + const close = vi.spyOn(DatabaseSync.prototype, 'close'); + try { + expect(await readOverviewDetails(configuration, [domain, 'new.example.test'])).toEqual({ + overview: details, + domains: [{ domain, details: domainDetails }] + }); + expect(close).toHaveBeenCalledTimes(1); + configuration.analytics.retention_months = 9; + expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull(); + expect(close).toHaveBeenCalledTimes(2); + } finally { + close.mockRestore(); + } expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull(); } finally { await rm(directory, { recursive: true, force: true }); } }); +test('invalid prepared output is unavailable, sanitized and closed', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goto-invalid-reports-')); + const configuration = { + database: { + path: join(directory, 'goto.sqlite'), + worker_path: join(directory, 'worker.sqlite') + }, + analytics: { retention_months: 3 as const } + }; + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const db = await openDb(configuration.database.path); + try { + await runMigrations(db); + await db + .insertInto('analytics_reports') + .values({ + scope_kind: 'overview', + domain: null, + path: null, + summary_json: '{"private":"not-for-browser"}', + details_json: '{}' + }) + .execute(); + } finally { + await db.destroy(); + } + const close = vi.spyOn(DatabaseSync.prototype, 'close'); + try { + expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull(); + expect(close).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalledExactlyOnceWith('Prepared visit reports unavailable'); + } finally { + close.mockRestore(); + } + } finally { + warning.mockRestore(); + await rm(directory, { recursive: true, force: true }); + } +}); + test('enabled collection fails safely without creating missing storage', async () => { const directory = await mkdtemp(join(tmpdir(), 'goto-missing-reports-')); const missing = join(directory, 'missing'); @@ -75,7 +149,11 @@ test('enabled collection fails safely without creating missing storage', async ( analytics: { retention_months: 3 as const } }; expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull(); - expect(warning).toHaveBeenCalledExactlyOnceWith('Prepared visit reports unavailable'); + expect(await readOverviewDetails(configuration, ['go.example.test'])).toBeNull(); + expect(warning.mock.calls).toEqual([ + ['Prepared visit reports unavailable'], + ['Prepared visit reports unavailable'] + ]); await expect(access(missing)).rejects.toMatchObject({ code: 'ENOENT' }); } finally { warning.mockRestore(); @@ -91,6 +169,7 @@ test('disabled collection does not attempt to open missing storage', async () => analytics: { retention_months: 0 as const } }; expect(await readVisitSummary(configuration, { kind: 'overview' })).toBeNull(); + expect(await readOverviewDetails(configuration, ['go.example.test'])).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 7d812c9..d00070b 100644 --- a/apps/web/src/lib/server/visit-reports.ts +++ b/apps/web/src/lib/server/visit-reports.ts @@ -1,11 +1,35 @@ import type { ReportScope, VisitSummary } from '@goto/analytics'; import type { GotoConfiguration } from '@goto/config'; import { openDb } from '@goto/db'; -import { readVisitSummary as readPreparedSummary } from '@goto/db/visit-reports'; +import { + readOverviewVisitDetails, + readVisitSummary as readPreparedSummary, + type OverviewVisitDetails +} from '@goto/db/visit-reports'; import type { Immutable } from './snapshot.js'; type ReportConfiguration = Immutable>; +export async function readOverviewDetails( + configuration: ReportConfiguration, + currentDomains: 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 readOverviewVisitDetails(db, currentDomains); + if (!report || report.overview.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 7e7948b..fbfdfe3 100644 --- a/apps/web/src/lib/styles/admin.css +++ b/apps/web/src/lib/styles/admin.css @@ -14,13 +14,19 @@ .admin-title-row { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: center; + justify-content: space-between; gap: var(--space-4) var(--space-5); } +.admin-title { + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); +} .admin-updated { - margin-inline-start: auto; - text-align: end; color: var(--color-text-muted); + font-size: var(--font-size-sm); } .admin-heading h1 { font-size: var(--font-size-heading); @@ -43,6 +49,20 @@ .admin-cards-summary { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.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); @@ -149,7 +169,8 @@ } } @media (max-width: 400px) { - .admin-cards-summary { + .admin-cards-summary, + .admin-cards-inventory { grid-template-columns: 1fr; } } 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 95b3a99..217efe3 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/+page.server.ts +++ b/apps/web/src/routes/(admin)/(auth)/admin/+page.server.ts @@ -1,25 +1,45 @@ -import { readVisitSummary } from '#lib/server/visit-reports.js'; -import type { OverviewSummary } from '@goto/analytics'; +import type { VisitDetails } from '@goto/analytics'; +import { + availableVisitTimeframes, + sumValidVisits, + visitWindow, + type VisitTimeframe, + type VisitWindow +} from '@goto/analytics/daily'; +import { readOverviewDetails } from '#lib/server/visit-reports.js'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals }) => { +export const load = (async ({ locals }) => { const snapshot = locals.snapshot; const configuration = snapshot.configuration; const retentionMonths = configuration.analytics.retention_months; const collectionEnabled = retentionMonths !== 0; - let overview: OverviewSummary | null = null; - if (collectionEnabled) { - const summary = await readVisitSummary(configuration, { kind: 'overview' }); - if (summary && 'domains' in summary) overview = summary; - } - const countsByDomain = new Map(overview?.domains.map((row) => [row.domain, row.totals])); - const domains = Object.entries(snapshot.domains).map(([domain, prepared]) => ({ - domain, - linkCount: Object.keys(prepared.links).length, - counts: countsByDomain.get(domain) ?? null, - href: `/admin/domains/${encodeURIComponent(domain)}` - })); + const currentDomains = Object.keys(snapshot.domains); + const report = collectionEnabled + ? await readOverviewDetails(configuration, currentDomains) + : null; + const timeframes = retentionMonths === 0 ? [] : availableVisitTimeframes(retentionMonths); + const overview = report?.overview ?? null; + const windows = overview + ? timeframes.map((timeframe) => ({ + timeframe, + window: visitWindow(overview.period, timeframe) + })) + : []; + 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); + return { + domain, + linkCount: Object.keys(prepared.links).length, + visitsByTimeframe, + href: `/admin/domains/${encodeURIComponent(domain)}` + }; + }); const asOf = overview ? Date.parse(overview.period.asOf) : null; + const visitsByTimeframe = countVisitWindows(overview?.daily, windows); + const chart = overview ? { daily: overview.daily, period: overview.period } : null; return { collectionEnabled, available: overview !== null, @@ -27,7 +47,21 @@ export const load: PageServerLoad = async ({ locals }) => { asOf, domainCount: domains.length, linkCount: domains.reduce((sum, row) => sum + row.linkCount, 0), - totals: overview?.totals ?? null, + timeframes, + visitsByTimeframe, + 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 e8ccc36..73e9730 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/+page.svelte +++ b/apps/web/src/routes/(admin)/(auth)/admin/+page.svelte @@ -1,20 +1,79 @@ + {#snippet actions()} + {#if data.available && data.timeframes.length > 0} +
+ {#each data.timeframes as option (option)} + + {/each} +
+ {/if} + {/snippet} -
+
- - + {#if data.collectionEnabled} + + + {/if}
+ {#if data.chart && window} + + {/if}

Domains

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

No domains configured.

@@ -23,7 +82,9 @@ {#if data.collectionEnabled}{/if} @@ -31,7 +92,9 @@ - + {#if data.collectionEnabled}{/if} {/each} 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 72c2253..f75dd17 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 @@ -1,25 +1,37 @@ import { validateConfig } from '@goto/config'; -import type { OverviewSummary } from '@goto/analytics'; +import { + createVisitPeriod, + visitDays, + type ReportPeriod, + 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 { readOverviewDetails } 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', () => ({ readOverviewDetails: vi.fn() })); beforeEach(() => vi.resetAllMocks()); +const domain = 'go.example.test'; +const emptyDomain = 'empty.example.test'; +const newDomain = 'new.example.test'; + function request(retention_months = 3) { const prepared = validateConfig( { auth: { credentials: { username: 'test', password: 'overview-test-password' } }, analytics: { retention_months }, domains: { - 'go.example.test': { + [domain]: { links: { '/docs': { url: 'https://example.test/docs' }, '/disabled': { url: 'https://example.test/disabled', enabled: false } } - } + }, + [emptyDomain]: { links: { '/': { url: 'https://example.test/' } } }, + [newDomain]: { links: { '/new': { url: 'https://example.test/new' } } } } }, { env: {} } @@ -27,46 +39,140 @@ function request(retention_months = 3) { return { locals: { snapshot: prepareSnapshot(prepared) } } as Parameters[0]; } -test('shows current configuration and the worker-prepared summary without re-aggregating', async () => { - const counts = { validVisits: 2, invalidVisits: 2 }; - const summary: OverviewSummary = { - version: 1, - scope: { kind: 'overview' }, - period: { - from: '2026-03-15T12:00:00.000Z', - asOf: '2026-06-15T12:00:00.000Z', - timezone: 'UTC', - retentionMonths: 3 - }, - totals: { validVisits: 7, invalidVisits: 2 }, - base: { validVisits: 0, invalidVisits: 1 }, - domains: [{ domain: 'go.example.test', totals: counts }] +function details( + scope: ReportScope, + period: ReportPeriod, + validVisits: (day: string) => number +): VisitDetails { + const daily = visitDays(period).map((day) => ({ + ...day, + validVisits: validVisits(day.start.slice(0, 10)), + invalidVisits: 1 + })); + const totals = { + validVisits: daily.reduce((sum, day) => sum + day.validVisits, 0), + invalidVisits: daily.length }; - vi.mocked(readVisitSummary).mockResolvedValue(summary); - const data = await load(request()); - if (!data) throw new Error('Missing page data'); - expect(readVisitSummary).toHaveBeenCalledExactlyOnceWith(expect.any(Object), { - kind: 'overview' + return { + version: 1, + scope, + period, + daily, + browsers: [{ family: 'Other', ...totals }], + operatingSystems: [{ family: 'Other', ...totals }] + }; +} + +test('projects compact global window counts while inventory stays current and history stays retained', async () => { + const period = createVisitPeriod(3, Date.parse('2026-06-15T12:00:00.000Z')); + const overviewCounts: Record = { + '2026-03-15': 13, + '2026-04-01': 11, + '2026-05-16': 7, + '2026-05-17': 5, + '2026-06-08': 3, + '2026-06-09': 2, + '2026-06-15': 1 + }; + const domainCounts: Record = { + '2026-03-15': 4, + '2026-05-16': 3, + '2026-05-17': 2, + '2026-06-08': 1, + '2026-06-09': 1, + '2026-06-15': 1 + }; + const overview = details({ kind: 'overview' }, period, (day) => overviewCounts[day] ?? 0); + const current = details({ kind: 'domain', domain }, period, (day) => domainCounts[day] ?? 0); + const empty = details({ kind: 'domain', domain: emptyDomain }, period, () => 0); + vi.mocked(readOverviewDetails).mockResolvedValue({ + overview, + domains: [ + { domain, details: current }, + { domain: emptyDomain, details: empty } + ] }); - expect(data.domainCount).toBe(1); - expect(data.linkCount).toBe(2); - expect(data.totals).toEqual(summary.totals); - expect(data.asOf).toBe(Date.parse(summary.period.asOf)); - expect(data.domains[0]).toMatchObject({ - href: '/admin/domains/go.example.test', - counts + const data = await load(request()); + expect(readOverviewDetails).toHaveBeenCalledExactlyOnceWith(expect.any(Object), [ + domain, + emptyDomain, + newDomain + ]); + expect(data).toEqual({ + collectionEnabled: true, + available: true, + retentionMonths: 3, + asOf: Date.parse(period.asOf), + domainCount: 3, + linkCount: 4, + timeframes: ['7d', '30d', 'all'], + visitsByTimeframe: { '7d': 3, '30d': 11, all: 42 }, + chart: { daily: overview.daily, period }, + domains: [ + { + domain, + linkCount: 2, + visitsByTimeframe: { '7d': 2, '30d': 5, all: 12 }, + href: '/admin/domains/go.example.test' + }, + { + domain: emptyDomain, + linkCount: 1, + visitsByTimeframe: { '7d': 0, '30d': 0, all: 0 }, + href: '/admin/domains/empty.example.test' + }, + { + domain: newDomain, + linkCount: 1, + visitsByTimeframe: {}, + href: '/admin/domains/new.example.test' + } + ] }); }); -test('disabled collection does not read a report; missing preparation is not zero', async () => { - const disabled = await load(request(0)); - if (!disabled) throw new Error('Missing disabled page'); - expect(disabled.collectionEnabled).toBe(false); - expect(disabled.totals).toBeNull(); - expect(readVisitSummary).not.toHaveBeenCalled(); - vi.mocked(readVisitSummary).mockResolvedValue(null); - const unavailable = await load(request()); - if (!unavailable) throw new Error('Missing unavailable page'); - expect(unavailable.available).toBe(false); - expect(unavailable.domains[0]?.counts).toBeNull(); +test.each([ + { months: 9 as const, retainedDays: 273 }, + { months: 12 as const, retainedDays: 365 } +])( + 'offers four windows for $months months anchored to the latest included day', + async ({ months, retainedDays }) => { + const period = createVisitPeriod(months, Date.parse('2026-03-01T00:00:00.000Z')); + const overview = details({ kind: 'overview' }, period, () => 1); + vi.mocked(readOverviewDetails).mockResolvedValue({ overview, domains: [] }); + 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); + } +); + +test('missing preparation keeps inventory usable without substituting zero counts', async () => { + vi.mocked(readOverviewDetails).mockResolvedValue(null); + const data = await load(request()); + if (!data) throw new Error('Missing unavailable page'); + expect(data.available).toBe(false); + expect(data.collectionEnabled).toBe(true); + expect(data.retentionMonths).toBe(3); + expect(data.domainCount).toBe(3); + 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([{}, {}, {}]); +}); + +test('disabled collection reads no analytics and exposes only inventory with empty counts', async () => { + const data = await load(request(0)); + if (!data) throw new Error('Missing disabled page'); + expect(data.collectionEnabled).toBe(false); + expect(data.available).toBe(false); + expect(data.domainCount).toBe(3); + expect(data.linkCount).toBe(4); + expect(data.timeframes).toEqual([]); + expect(data.visitsByTimeframe).toEqual({}); + expect(data.chart).toBeNull(); + expect(data.domains.map((row) => row.visitsByTimeframe)).toEqual([{}, {}, {}]); + expect(readOverviewDetails).not.toHaveBeenCalled(); }); diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile index 5e6538b..aff0880 100644 --- a/apps/worker/Dockerfile +++ b/apps/worker/Dockerfile @@ -3,24 +3,26 @@ FROM node:26.5.0-alpine AS base WORKDIR /m ENV CI=true \ - HOME=/home/app \ - PNPM_HOME=/home/app/.pnpm \ - XDG_DATA_HOME=/home/app/.local/share \ - XDG_CACHE_HOME=/home/app/.cache \ - XDG_CONFIG_HOME=/home/app/.config + HOME=/root RUN npm install -g pnpm@11.9.0 \ - && mkdir -p /home/app/store \ - && printf 'store-dir=/home/app/store\n' > /home/app/.npmrc + && mkdir -p /pnpm/store \ + && printf 'store-dir=/pnpm/store\n' > /root/.npmrc FROM base AS dev ARG LOCAL_UID=1000 ARG LOCAL_GID=1000 COPY pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --frozen-lockfile COPY . . -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile -RUN mkdir -p /m/apps/worker/config /m/apps/worker/data /m/packages/config/dist \ +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile +ENV HOME=/home/app \ + PNPM_HOME=/home/app/.pnpm \ + XDG_DATA_HOME=/home/app/.local/share \ + XDG_CACHE_HOME=/home/app/.cache \ + XDG_CONFIG_HOME=/home/app/.config +RUN mkdir -p /home/app/store /m/apps/worker/config /m/apps/worker/data /m/packages/config/dist \ + && printf 'store-dir=/home/app/store\n' > /home/app/.npmrc \ && chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/apps/worker/data /m/packages/config/dist USER ${LOCAL_UID}:${LOCAL_GID} WORKDIR /m/apps/worker @@ -29,13 +31,13 @@ CMD ["pnpm", "run", "dev"] FROM base AS builder COPY pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --frozen-lockfile COPY . . -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --offline --frozen-lockfile RUN pnpm --filter "@goto/worker..." build FROM builder AS pruner -RUN --mount=type=cache,id=pnpm-store,target=/home/app/store \ +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ pnpm --config.hoist-workspace-packages=false --filter @goto/worker --prod deploy --legacy /pruned FROM node:26.5.0-alpine AS prod diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 7a7da12..9771827 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -8,6 +8,11 @@ "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./daily": { + "development": "./src/daily.ts", + "types": "./dist/daily.d.ts", + "import": "./dist/daily.js" } }, "files": [ diff --git a/packages/analytics/src/daily.test.ts b/packages/analytics/src/daily.test.ts new file mode 100644 index 0000000..b0e3d7d --- /dev/null +++ b/packages/analytics/src/daily.test.ts @@ -0,0 +1,245 @@ +import { expect, test } from 'vitest'; +import { + availableVisitTimeframes, + groupVisitDays, + sumValidVisits, + visitGranularity, + visitWindow +} from './daily.js'; +import { visitDays } from './period.js'; +import type { ReportPeriod, VisitDetails } from './reports.js'; + +function dailyReport( + from: string, + asOf: string, + counts: Record = {} +): { period: ReportPeriod; daily: VisitDetails['daily'] } { + const period: ReportPeriod = { from, asOf, timezone: 'UTC', retentionMonths: 3 }; + const daily = visitDays(period).map((day) => { + const [validVisits, invalidVisits] = counts[day.start.slice(0, 10)] ?? [0, 0]; + return { ...day, validVisits, invalidVisits }; + }); + return { period, daily }; +} + +test('timeframe choices and automatic granularity follow retention', () => { + expect(availableVisitTimeframes(3)).toEqual(['7d', '30d', 'all']); + expect(availableVisitTimeframes(9)).toEqual(['7d', '30d', '90d', 'all']); + expect(availableVisitTimeframes(12)).toEqual(['7d', '30d', '90d', 'all']); + expect(visitGranularity('7d', 3)).toBe('day'); + expect(visitGranularity('30d', 9)).toBe('day'); + expect(visitGranularity('90d', 12)).toBe('week'); + expect(visitGranularity('all', 3)).toBe('week'); + expect(visitGranularity('all', 9)).toBe('month'); + expect(visitGranularity('all', 12)).toBe('month'); +}); + +test('day presets include the latest UTC calendar day rather than rolling hours', () => { + const period: ReportPeriod = { + from: '2023-12-01T12:00:00.000Z', + asOf: '2024-03-01T12:00:00.000Z', + timezone: 'UTC', + retentionMonths: 3 + }; + + expect(visitWindow(period, '7d')).toEqual({ + from: '2024-02-24T00:00:00.000Z', + asOf: period.asOf + }); + expect(visitWindow({ ...period, asOf: '2024-03-01T00:00:00.000Z' }, '7d')).toEqual({ + from: '2024-02-23T00:00:00.000Z', + asOf: '2024-03-01T00:00:00.000Z' + }); + expect(visitWindow(period, '30d')).toEqual({ + from: '2024-02-01T00:00:00.000Z', + asOf: period.asOf + }); + expect(visitWindow(period, '90d')).toEqual({ + from: '2023-12-03T00:00:00.000Z', + asOf: period.asOf + }); +}); + +test('full windows preserve partial retention boundaries and presets cannot precede them', () => { + const { period, daily } = dailyReport('2024-02-28T12:00:00.000Z', '2024-03-01T12:00:00.000Z', { + '2024-02-28': [2, 1], + '2024-02-29': [3, 0], + '2024-03-01': [4, 1] + }); + const original = { ...period }; + const window = visitWindow(period, 'all'); + + expect(window).toEqual({ from: period.from, asOf: period.asOf }); + expect(visitWindow(period, '7d')).toEqual(window); + expect(sumValidVisits(daily, window)).toBe(9); + expect(groupVisitDays(daily, window, 'day')).toEqual(daily); + expect(period).toEqual(original); +}); + +test('selected days are filtered before grouping and share the headline total without mutation', () => { + 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 original = structuredClone(daily); + const window = visitWindow(period, '7d'); + const weeks = groupVisitDays(daily, window, 'week'); + + expect(window.from).toBe('2024-02-27T00:00:00.000Z'); + expect(weeks).toEqual([ + { + start: '2024-02-26T00:00:00.000Z', + end: '2024-03-04T00:00:00.000Z', + partial: true, + validVisits: 5, + invalidVisits: 51 + }, + { + start: '2024-03-04T00:00:00.000Z', + end: '2024-03-11T00:00:00.000Z', + partial: true, + validVisits: 4, + invalidVisits: 2 + } + ]); + expect(sumValidVisits(daily, window)).toBe(9); + expect(weeks.reduce((total, week) => total + week.validVisits, 0)).toBe(9); + expect(daily).toEqual(original); +}); + +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], + '2024-02-28': [2, 1], + '2024-02-29': [3, 2], + '2024-03-01': [100, 100] + }); + const window = { from: '2024-02-28T00:00:00.000Z', asOf: '2024-03-01T00:00:00.000Z' }; + + expect(sumValidVisits(daily, window)).toBe(5); + expect(groupVisitDays(daily, window, 'day')).toEqual(daily.slice(1, 3)); +}); + +test('day grouping preserves UTC leap days, zero counts and partial window edges', () => { + const { period, daily } = dailyReport('2024-02-28T12:00:00.000Z', '2024-03-01T12:00:00.000Z', { + '2024-02-28': [2, 1], + '2024-03-01': [4, 3] + }); + + expect(groupVisitDays(daily, period, 'day')).toEqual([ + { + start: '2024-02-28T00:00:00.000Z', + end: '2024-02-29T00:00:00.000Z', + partial: true, + validVisits: 2, + invalidVisits: 1 + }, + { + start: '2024-02-29T00:00:00.000Z', + end: '2024-03-01T00:00:00.000Z', + partial: false, + validVisits: 0, + invalidVisits: 0 + }, + { + start: '2024-03-01T00:00:00.000Z', + end: '2024-03-02T00:00:00.000Z', + partial: true, + validVisits: 4, + invalidVisits: 3 + } + ]); +}); + +test('weeks start on UTC Monday across years and sum both outcomes without changing input', () => { + const { period, daily } = dailyReport('2023-12-31T12:00:00.000Z', '2024-01-09T12:00:00.000Z', { + '2023-12-31': [2, 1], + '2024-01-08': [4, 3], + '2024-01-09': [1, 2] + }); + const original = structuredClone(daily); + + expect(groupVisitDays(daily, period, 'week')).toEqual([ + { + start: '2023-12-25T00:00:00.000Z', + end: '2024-01-01T00:00:00.000Z', + partial: true, + validVisits: 2, + invalidVisits: 1 + }, + { + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-08T00:00:00.000Z', + partial: false, + validVisits: 0, + invalidVisits: 0 + }, + { + start: '2024-01-08T00:00:00.000Z', + end: '2024-01-15T00:00:00.000Z', + partial: true, + validVisits: 5, + invalidVisits: 5 + } + ]); + expect(daily).toEqual(original); +}); + +test('calendar months preserve empty months and include leap-day outcomes in February', () => { + const { period, daily } = dailyReport('2023-12-31T12:00:00.000Z', '2024-03-01T12:00:00.000Z', { + '2023-12-31': [2, 1], + '2024-02-28': [3, 2], + '2024-02-29': [4, 1], + '2024-03-01': [1, 5] + }); + + expect(groupVisitDays(daily, period, 'month')).toEqual([ + { + start: '2023-12-01T00:00:00.000Z', + end: '2024-01-01T00:00:00.000Z', + partial: true, + validVisits: 2, + invalidVisits: 1 + }, + { + start: '2024-01-01T00:00:00.000Z', + end: '2024-02-01T00:00:00.000Z', + partial: false, + validVisits: 0, + invalidVisits: 0 + }, + { + start: '2024-02-01T00:00:00.000Z', + end: '2024-03-01T00:00:00.000Z', + partial: false, + validVisits: 7, + invalidVisits: 3 + }, + { + start: '2024-03-01T00:00:00.000Z', + end: '2024-04-01T00:00:00.000Z', + partial: true, + validVisits: 1, + invalidVisits: 5 + } + ]); +}); + +test('exact calendar boundaries are complete and do not add a following bucket', () => { + const { period, daily } = dailyReport('2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'); + + expect(groupVisitDays(daily, period, 'month')).toEqual([ + { + start: '2024-02-01T00:00:00.000Z', + end: '2024-03-01T00:00:00.000Z', + partial: false, + validVisits: 0, + invalidVisits: 0 + } + ]); + expect(sumValidVisits(daily, period)).toBe(0); + expect(sumValidVisits([], period)).toBe(0); + expect(groupVisitDays([], period, 'month')).toEqual([]); +}); diff --git a/packages/analytics/src/daily.ts b/packages/analytics/src/daily.ts new file mode 100644 index 0000000..16b0b81 --- /dev/null +++ b/packages/analytics/src/daily.ts @@ -0,0 +1,99 @@ +import type { ReportPeriod, VisitDetails } from './reports.js'; + +export type VisitTimeframe = '7d' | '30d' | '90d' | 'all'; +export type VisitWindow = { from: string; asOf: string }; +export type VisitGranularity = 'day' | 'week' | 'month'; + +const daysByTimeframe = { '7d': 7, '30d': 30, '90d': 90 }; + +export function availableVisitTimeframes(months: 3 | 9 | 12): VisitTimeframe[] { + return months === 3 ? ['7d', '30d', 'all'] : ['7d', '30d', '90d', 'all']; +} + +export function visitWindow(period: ReportPeriod, timeframe: VisitTimeframe): VisitWindow { + if (timeframe === 'all') { + return { from: period.from, asOf: period.asOf }; + } + + const days = daysByTimeframe[timeframe]; + const start = new Date(Date.parse(period.asOf) - 1); + start.setUTCHours(0, 0, 0, 0); + start.setUTCDate(start.getUTCDate() - (days - 1)); + return { + from: new Date(Math.max(start.getTime(), Date.parse(period.from))).toISOString(), + asOf: period.asOf + }; +} + +export function sumValidVisits(daily: VisitDetails['daily'], window: VisitWindow): number { + const from = Date.parse(window.from); + const asOf = Date.parse(window.asOf); + let total = 0; + for (const day of daily) { + if (intersectsWindow(day, from, asOf)) { + total += day.validVisits; + } + } + return total; +} + +export function visitGranularity(timeframe: VisitTimeframe, months: 3 | 9 | 12): VisitGranularity { + if (timeframe === '7d' || timeframe === '30d') return 'day'; + if (timeframe === '90d' || months === 3) return 'week'; + return 'month'; +} + +export function groupVisitDays( + daily: VisitDetails['daily'], + window: VisitWindow, + granularity: VisitGranularity +): VisitDetails['daily'] { + const groups: VisitDetails['daily'] = []; + const from = Date.parse(window.from); + const asOf = Date.parse(window.asOf); + + for (const day of daily) { + if (!intersectsWindow(day, from, asOf)) continue; + + const start = groupStart(day.start, granularity); + const startInstant = start.toISOString(); + const previous = groups.at(-1); + if (previous?.start === startInstant) { + previous.validVisits += day.validVisits; + previous.invalidVisits += day.invalidVisits; + continue; + } + + const end = new Date(start); + if (granularity === 'month') { + end.setUTCMonth(end.getUTCMonth() + 1); + } else { + end.setUTCDate(end.getUTCDate() + (granularity === 'week' ? 7 : 1)); + } + groups.push({ + start: startInstant, + end: end.toISOString(), + partial: start.getTime() < from || end.getTime() > asOf, + validVisits: day.validVisits, + invalidVisits: day.invalidVisits + }); + } + + return groups; +} + +function intersectsWindow(day: VisitDetails['daily'][number], from: number, asOf: number): boolean { + return Date.parse(day.start) < asOf && Date.parse(day.end) > from; +} + +function groupStart(instant: string, granularity: VisitGranularity): Date { + const start = new Date(instant); + start.setUTCHours(0, 0, 0, 0); + if (granularity === 'week') { + const daysSinceMonday = (start.getUTCDay() + 6) % 7; + start.setUTCDate(start.getUTCDate() - daysSinceMonday); + } else if (granularity === 'month') { + start.setUTCDate(1); + } + return start; +} diff --git a/packages/analytics/src/decode.test.ts b/packages/analytics/src/decode.test.ts index 383d438..dd91bf3 100644 --- a/packages/analytics/src/decode.test.ts +++ b/packages/analytics/src/decode.test.ts @@ -30,6 +30,26 @@ test('readers decode their self-contained document independently', () => { expect(parseVisitDetails(JSON.stringify(details), scope)).toEqual(details); }); +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)); + const nextDetails = { + ...details, + period: nextPeriod, + daily: visitDays(nextPeriod).map((day) => ({ ...day, ...zeroVisitTotals() })), + browsers: [], + operatingSystems: [] + }; + expect(parseVisitDetails(JSON.stringify(nextDetails), scope)).toEqual(nextDetails); + expect(() => parseVisitDetails(JSON.stringify(nextDetails), scope, period)).toThrow( + 'Invalid analytics report' + ); + const otherPolicy = createVisitPeriod(9, Date.parse(period.asOf)); + expect(() => parseVisitDetails(JSON.stringify(details), scope, otherPolicy)).toThrow( + 'Invalid analytics report' + ); +}); + test('summary reader rejects unsupported versions, unsafe counts and wrong identities safely', () => { for (const invalid of [ { ...summary, version: 2 }, diff --git a/packages/analytics/src/decode.ts b/packages/analytics/src/decode.ts index 1b058df..da696e1 100644 --- a/packages/analytics/src/decode.ts +++ b/packages/analytics/src/decode.ts @@ -1,5 +1,5 @@ -import type { ReportScope, VisitDetails, VisitSummary } from './reports.js'; -import { checkDetails, checkSummary, invalidReport, sameScope } from './validation.js'; +import type { ReportPeriod, ReportScope, VisitDetails, VisitSummary } from './reports.js'; +import { checkDetails, checkSummary, invalidReport, samePeriod, sameScope } from './validation.js'; export function parseVisitSummary(json: string, expectedScope: ReportScope): VisitSummary { const document = parseDocument(json); @@ -8,10 +8,15 @@ export function parseVisitSummary(json: string, expectedScope: ReportScope): Vis return summary; } -export function parseVisitDetails(json: string, expectedScope: ReportScope): VisitDetails { +export function parseVisitDetails( + json: string, + expectedScope: ReportScope, + expectedPeriod?: ReportPeriod +): VisitDetails { const document = parseDocument(json); const details = checkDetails(document); if (!sameScope(details.scope, expectedScope)) throw invalidReport(); + if (expectedPeriod && !samePeriod(details.period, expectedPeriod)) throw invalidReport(); return details; } diff --git a/packages/db/src/visit-reports/index.ts b/packages/db/src/visit-reports/index.ts index c4b496e..e6c3080 100644 --- a/packages/db/src/visit-reports/index.ts +++ b/packages/db/src/visit-reports/index.ts @@ -2,9 +2,11 @@ export { aggregateVisitReports } from './queries.js'; export { clearVisitReports, publishVisitReports, + readOverviewVisitDetails, readVisitSummary, readVisitDetails } from './storage.js'; +export type { OverviewVisitDetails } 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 472831d..f670f31 100644 --- a/packages/db/src/visit-reports/storage.test.ts +++ b/packages/db/src/visit-reports/storage.test.ts @@ -16,6 +16,7 @@ import { clearVisitReports, publishVisitReports, readVisitDetails, + readOverviewVisitDetails, readVisitSummary } from './storage.js'; @@ -38,9 +39,10 @@ afterEach(async () => { function preparedReport( scope: ReportScope, cutoff = asOf, - totals: VisitTotals = zeroVisitTotals() + totals: VisitTotals = zeroVisitTotals(), + retentionMonths: 3 | 9 | 12 = 3 ): PreparedVisitReport { - const period = createVisitPeriod(3, cutoff); + const period = createVisitPeriod(retentionMonths, cutoff); let summary: VisitSummary; if (scope.kind === 'overview') { summary = { version: 1, scope, period, totals, base: zeroVisitTotals(), domains: [] }; @@ -91,6 +93,90 @@ test('reads summary and details for each explicit SQL scope including root', asy ]); }); +test('overview details use one SQL snapshot with only current domain details and no summaries', async () => { + const overview = preparedReport(overviewScope, asOf, { validVisits: 8, invalidVisits: 2 }); + const current = preparedReport(domainScope, asOf, { validVisits: 3, invalidVisits: 1 }); + const historical = preparedReport({ kind: 'domain', domain: 'removed.example.test' }); + await publishVisitReports( + db, + encodeVisitReports([overview, current, historical, preparedReport(rootScope)]) + ); + await db.updateTable('analytics_reports').set({ summary_json: '{}' }).execute(); + await db + .updateTable('analytics_reports') + .set({ details_json: '{}' }) + .where(({ or, eb }) => + or([eb('scope_kind', '=', 'link'), eb('domain', '=', 'removed.example.test')]) + ) + .execute(); + const statements = vi.spyOn(DatabaseSync.prototype, 'prepare'); + try { + expect(await readOverviewVisitDetails(db, [domain, 'new.example.test'])).toEqual({ + overview: overview.details, + domains: [{ domain, details: current.details }] + }); + expect(statements).toHaveBeenCalledTimes(1); + const query = statements.mock.calls[0]?.[0]; + expect(query).toMatch( + /^select\s+"scope_kind",\s*"domain",\s*"details_json"\s+from\s+"analytics_reports"/i + ); + expect(query).not.toMatch(/summary_json|\bvisits\b|\bjoin\b/i); + } finally { + statements.mockRestore(); + } +}); + +test('overview details preserve missing reports rather than inventing zero domains', async () => { + expect(await readOverviewVisitDetails(db, [domain])).toBeNull(); + const current = preparedReport(domainScope); + await publishVisitReports(db, encodeVisitReports([current])); + expect(await readOverviewVisitDetails(db, [domain])).toBeNull(); + const overview = preparedReport(overviewScope); + await publishVisitReports(db, encodeVisitReports([overview, current])); + expect(await readOverviewVisitDetails(db, [])).toEqual({ + overview: overview.details, + domains: [] + }); + expect(await readOverviewVisitDetails(db, ['new.example.test'])).toEqual({ + overview: overview.details, + domains: [] + }); +}); + +test('overview details reject malformed or mismatched domain scope, policy and window', async () => { + const overview = preparedReport(overviewScope); + const current = preparedReport(domainScope); + await publishVisitReports(db, encodeVisitReports([overview, current])); + for (const details of [ + {}, + { ...current.details, version: 2 }, + overview.details, + { ...current.details, scope: { kind: 'domain', domain: 'wrong.example.test' } }, + preparedReport(domainScope, asOf + 86_400_000).details, + preparedReport(domainScope, asOf, zeroVisitTotals(), 9).details + ]) { + await db + .updateTable('analytics_reports') + .set({ details_json: JSON.stringify(details) }) + .where('scope_kind', '=', 'domain') + .execute(); + await expect(readOverviewVisitDetails(db, [domain])).rejects.toThrow( + /^Invalid analytics report$/ + ); + } +}); + +test('overview details reject an invalid overview independently of summary JSON', async () => { + await publishVisitReports(db, encodeVisitReports([preparedReport(overviewScope)])); + for (const details of [{}, preparedReport(domainScope).details]) { + await db + .updateTable('analytics_reports') + .set({ details_json: JSON.stringify(details) }) + .execute(); + await expect(readOverviewVisitDetails(db, [])).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 2a81d6b..050e1de 100644 --- a/packages/db/src/visit-reports/storage.ts +++ b/packages/db/src/visit-reports/storage.ts @@ -36,6 +36,46 @@ export async function publishVisitReports( }); } +export interface OverviewVisitDetails { + overview: VisitDetails; + domains: Array<{ domain: string; details: VisitDetails }>; +} + +export async function readOverviewVisitDetails( + db: Kysely, + currentDomains: string[] +): Promise { + const rows = await db + .selectFrom('analytics_reports') + .select(['scope_kind', 'domain', 'details_json']) + .where(({ and, or, eb }) => { + const overview = eb('scope_kind', '=', 'overview'); + if (currentDomains.length === 0) return overview; + return or([ + overview, + and([eb('scope_kind', '=', 'domain'), eb('domain', 'in', currentDomains)]) + ]); + }) + .execute(); + const overviewRow = rows.find((row) => row.scope_kind === 'overview'); + if (!overviewRow) return null; + const overview = parseVisitDetails(overviewRow.details_json, { kind: 'overview' }); + const domains: OverviewVisitDetails['domains'] = []; + for (const row of rows) { + if (row.scope_kind === 'overview') continue; + if (row.scope_kind !== 'domain' || row.domain === null) { + throw new Error('Invalid analytics report'); + } + const details = parseVisitDetails( + row.details_json, + { kind: 'domain', domain: row.domain }, + overview.period + ); + domains.push({ domain: row.domain, details }); + } + return { overview, domains }; +} + export async function readVisitSummary( db: Kysely, scope: ReportScope diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a41bdff..3d35fd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ catalogs: better-auth: specifier: 1.7.14 version: 1.7.14 + chart.js: + specifier: 4.6.1 + version: 4.6.1 esbuild: specifier: 0.28.2 version: 0.28.2 @@ -178,6 +181,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) + chart.js: + specifier: 'catalog:' + version: 4.6.1 piscina: specifier: 'catalog:' version: 5.3.2 @@ -730,6 +736,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@manypkg/find-root@3.1.0': resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} engines: {node: '>=20.0.0'} @@ -1259,6 +1268,10 @@ packages: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + chart.js@4.6.1: + resolution: {integrity: sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g==} + engines: {pnpm: '>=8'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2428,6 +2441,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@kurkle/color@0.3.4': {} + '@manypkg/find-root@3.1.0': dependencies: '@manypkg/tools': 2.1.2 @@ -2866,6 +2881,10 @@ snapshots: chai@6.3.0: {} + chart.js@4.6.1: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 332b003..8334b81 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ catalog: '@types/node': 24.12.4 '@vitest/coverage-v8': 4.1.7 better-auth: 1.7.14 + chart.js: 4.6.1 esbuild: 0.28.2 eslint: 10.4.1 eslint-config-prettier: 10.1.8
DomainLinksVisits
DomainLinksVisits
{row.domain} {formatCount(row.linkCount)}{formatCount(row.counts?.validVisits ?? null)}{formatCount(row.visitsByTimeframe[timeframe] ?? null)}