diff --git a/.changeset/domain-visits-chart.md b/.changeset/domain-visits-chart.md new file mode 100644 index 0000000..85de78e --- /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. 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 835612b..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,8 +101,21 @@ 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(); - await expect(page.getByRole('region', { name: 'Visits over time', exact: true })).toHaveCount(0); + 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( + 'aria-pressed', + 'true' + ); await expect(visitsValue).toHaveText('2'); await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText([ 'Links', @@ -112,19 +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 historicalInvalid = 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('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(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(retainedVisits).toHaveText('1'); + await expect(graph).toHaveAccessibleName(/^Valid visits by week\. Total: 2\./); + 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 }) @@ -132,7 +182,10 @@ 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( page.getByRole('region', { name: 'Links on go.example.org', exact: true }) @@ -140,22 +193,29 @@ 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(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'); @@ -184,8 +244,26 @@ 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(); + 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 }) + .locator('.admin-inventory-heading > span') + ).toHaveText(['Path', 'Destination', 'State']); + await expect( + page + .getByRole('region', { name: 'Base URL on go.example.org', exact: true }) + .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 @@