ReviewOS

also looking at this

stacks/analyticshq

Add BunPress product guide and refresh analytics dashboard

#51
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
21 files +1173 -178
config/cloud.tsmodified+12-5
Changes to config/cloud.ts
@@ -702,18 +702,16 @@ export const tsCloud: TsCloudConfig = {
702702 * Hetzner path (`deployAllComputeSites`) builds each site's `root`, tars it,
703703 * and ships it to `/var/www/<siteName>` on the box. No new Hetzner buckets are
704704 * created. Each site's key maps 1:1 to `/var/www/<key>`:
705 * - `docs` → /var/www/docs → served at /docs on stacksjs.com
706 * - `blog` → /var/www/blog → served at /blog on stacksjs.com
705 * - `docs` -> /var/www/docs -> served at /docs on analyticshq.org
707706 *
708707 * The Stacks root is served by the `main` server app; do not add a second
709 * static `/` site for stacksjs.com or it will compete with the app route.
708 * static `/` site for analyticshq.org or it will compete with the app route.
710709 */
711710 sites: {
712711 // The analyticshq app itself — a Bun server (`buddy serve`) that renders
713712 // the stx dashboard + serves the /collect ingest + /api/* stats routes on
714713 // :3000, fronted by the reverse proxy on analyticshq.org. This is the
715 // ONLY site: no docs/blog/marketing static sites (this is a single-purpose
716 // analytics app, not the stacks.com monorepo the scaffold was cloned from).
714 // main application site, with BunPress documentation mounted at /docs.
717715 //
718716 // On the shared `stacks` box (attachTo) ts-cloud namespaces every install
719717 // dir by project slug — this ships to /var/www/analyticshq-main via the
@@ -826,6 +824,15 @@ export const tsCloud: TsCloudConfig = {
826824 env: { HOST: '127.0.0.1', APP_ENV: 'production', NODE_ENV: 'production' },
827825 },
828826
827 // BunPress documentation, built as static files and mounted under /docs.
828 docs: {
829 root: './dist/docs/.bunpress',
830 path: '/docs',
831 domain: env.APP_DOMAIN || 'analyticshq.org',
832 deploy: 'server',
833 build: 'PATH="$PWD/node_modules/.bin:$PATH" bun node_modules/@stacksjs/buddy/dist/cli.js build docs && test -d dist/docs/.bunpress',
834 },
835
829836 // www → apex redirect.
830837 www: { domain: `www.${env.APP_DOMAIN || 'analyticshq.org'}`, redirect: `https://${env.APP_DOMAIN || 'analyticshq.org'}` },
831838
config/docs.tsadded+102-0
Changes to config/docs.ts
@@ -0,0 +1,102 @@
1import type { BunPressOptions } from '@stacksjs/bunpress'
2
3const config: BunPressOptions = {
4 verbose: false,
5 docsDir: './docs',
6 outDir: './dist/docs',
7
8 nav: [
9 { text: 'Quick start', link: '/getting-started' },
10 { text: 'Tracking', link: '/tracking/install' },
11 { text: 'Reports', link: '/reports/dashboard' },
12 { text: 'Dashboard', link: 'https://analyticshq.org/dashboard' },
13 { text: 'GitHub', link: 'https://github.com/stacksjs/analyticshq' },
14 ],
15
16 markdown: {
17 title: 'AnalyticsHQ Documentation',
18 meta: {
19 description: 'Privacy-first web analytics, goals, funnels, revenue, Web Vitals, imports, and self-hosting.',
20 author: 'AnalyticsHQ',
21 },
22 syntaxHighlightTheme: 'github-dark',
23 toc: {
24 enabled: true,
25 minDepth: 2,
26 maxDepth: 3,
27 },
28 sidebar: {
29 '/': [
30 {
31 text: 'Introduction',
32 items: [
33 { text: 'What is AnalyticsHQ', link: '/introduction' },
34 { text: 'Quick start', link: '/getting-started' },
35 ],
36 },
37 {
38 text: 'Collect data',
39 items: [
40 { text: 'Install tracking', link: '/tracking/install' },
41 { text: 'Custom events', link: '/tracking/custom-events' },
42 { text: 'Custom domains', link: '/tracking/custom-domains' },
43 ],
44 },
45 {
46 text: 'Understand traffic',
47 items: [
48 { text: 'Dashboard reports', link: '/reports/dashboard' },
49 { text: 'Filters and segments', link: '/reports/filters-segments' },
50 { text: 'Goals and funnels', link: '/reports/goals-funnels' },
51 { text: 'Revenue and Web Vitals', link: '/reports/revenue-vitals' },
52 ],
53 },
54 {
55 text: 'Move data',
56 items: [
57 { text: 'Fathom import', link: '/imports/fathom' },
58 { text: 'GA4 and Search Console', link: '/imports/google' },
59 { text: 'Backup and restore', link: '/imports/backup-restore' },
60 ],
61 },
62 {
63 text: 'Manage',
64 items: [
65 { text: 'Teams and sharing', link: '/manage/teams-sharing' },
66 { text: 'Privacy model', link: '/privacy' },
67 { text: 'Self-hosting', link: '/self-hosting' },
68 ],
69 },
70 {
71 text: 'Reference',
72 items: [
73 { text: 'HTTP API', link: '/reference/api' },
74 { text: 'CLI', link: '/reference/cli' },
75 ],
76 },
77 ],
78 },
79 },
80
81 themeConfig: {
82 darkMode: 'auto',
83 footer: {
84 message: 'Privacy-first analytics, released under the MIT License.',
85 copyright: 'Copyright 2026-present AnalyticsHQ',
86 },
87 socialLinks: [
88 { icon: 'github', link: 'https://github.com/stacksjs/analyticshq' },
89 ],
90 },
91
92 sitemap: {
93 enabled: true,
94 baseUrl: 'https://analyticshq.org/docs',
95 },
96
97 robots: {
98 enabled: true,
99 },
100}
101
102export default config
docs/getting-started.mdadded+48-0
Changes to docs/getting-started.md
@@ -0,0 +1,48 @@
1---
2title: Quick start
3description: Create a site, install the tracker, and verify the first page view.
4---
5
6# Quick start
7
8## 1. Create an account and site
9
10Open [analyticshq.org/register](https://analyticshq.org/register), create an account, and add the site you want to measure. AnalyticsHQ generates an App ID for that site.
11
12## 2. Add the tracker
13
14Copy the installation snippet from the site dashboard. The portable HTML form is:
15
16```html
17<script
18 defer
19 src="https://analyticshq.org/script.js"
20 data-site="YOUR_APP_ID"
21></script>
22```
23
24Add it once to the document head. The script derives the collection endpoint from its own URL, so hosted, self-hosted, local, and verified custom-domain installations all use the same artifact.
25
26Framework-specific examples are in [Install tracking](/tracking/install).
27
28## 3. Verify collection
29
30Visit the tracked site, navigate to a second page, and open the AnalyticsHQ dashboard. The realtime report should show activity shortly after the beacon reaches `POST /collect`.
31
32You can also verify the public tracker and health endpoint:
33
34```bash
35curl -I https://analyticshq.org/script.js
36curl https://analyticshq.org/api/health
37```
38
39## 4. Set up useful outcomes
40
41- Create a [goal](/reports/goals-funnels) for a success page or custom event.
42- Add campaign parameters to acquisition links.
43- Enable revenue collection only where you have a meaningful transaction value.
44- Review [privacy controls](/privacy) before enabling optional region data.
45
46## Troubleshooting
47
48If no data appears, confirm that the App ID is exact, the script returns JavaScript rather than an HTML error page, and the browser is not blocking the request. Do Not Track and Global Privacy Control are respected by default, so a protected browser may intentionally send nothing.
docs/imports/backup-restore.mdadded+42-0
Changes to docs/imports/backup-restore.md
@@ -0,0 +1,42 @@
1---
2title: Backup and restore
3description: Export a complete site archive, restore it, or clone it under a new site identifier.
4---
5
6# Backup and restore
7
8The standalone tools connect directly to PostgreSQL through `Bun.SQL`. Supply individual database variables or `DATABASE_URL`.
9
10## Export a site
11
12```bash
13bun run export:site -- --site=SITE_ID --out=backup.ndjson
14```
15
16The NDJSON archive includes sites, goals, sessions, page views, custom events, and conversions in foreign-key-safe order. Large tables use keyset pagination.
17
18Export one table for spreadsheet analysis:
19
20```bash
21bun run export:site -- \
22 --site=SITE_ID \
23 --format=csv \
24 --table=page_views \
25 --out=page-views.csv
26```
27
28## Restore
29
30```bash
31bun run import:site -- --in=backup.ndjson
32```
33
34IDs are preserved and conflicts are ignored, which makes the operation safe to retry.
35
36## Clone to another site
37
38```bash
39bun run import:site -- --in=backup.ndjson --site=NEW_SITE_ID
40```
41
42Cloning rewrites the site ID and prefixes dependent identifiers so the copy remains independent of the source.
docs/imports/fathom.mdadded+32-0
Changes to docs/imports/fathom.md
@@ -0,0 +1,32 @@
1---
2title: Import from Fathom
3description: Import a Fathom dashboard export or backfill through the Fathom API.
4---
5
6# Import from Fathom
7
8The dashboard upload is a Pro capability on the hosted service and is available without a paid plan when self-hosted.
9
10## Dashboard export
11
12Export the standard dashboard data from Fathom and upload the ZIP file exactly as Fathom provides it. AnalyticsHQ previews the date range and totals before writing data.
13
14The importer reads the summary, pages, countries, devices, browsers, and operating systems needed to reproduce Fathom's main totals. Large date ranges are split into safe windows.
15
16Fathom Custom Export, the queued email export whose files are named `pageviews-*.csv`, is detected and refused. Referrers, entry and exit pages, events, and UTM breakdown files are currently reported as skipped rather than partially imported.
17
18## API backfill CLI
19
20```bash
21bun run import:fathom -- \
22 --token=FATHOM_API_TOKEN \
23 --fathom-site=FATHOM_SITE_ID \
24 --site=ANALYTICSHQ_SITE_ID \
25 --from=2024-01-01 \
26 --to=2026-01-01 \
27 --dry-run
28```
29
30Remove `--dry-run` after reviewing the totals. Add `--replace` to remove a prior synthetic Fathom import for the same site before writing it again. Real AnalyticsHQ rows are not deleted by that option.
31
32Fathom supplies aggregates rather than raw visits. AnalyticsHQ creates synthetic sessions and page views that reproduce the reported totals, so historical visitor and session identities are approximate.
docs/imports/google.mdadded+33-0
Changes to docs/imports/google.md
@@ -0,0 +1,33 @@
1---
2title: GA4 and Search Console
3description: Bring historical Google Analytics and search performance data into AnalyticsHQ.
4---
5
6# GA4 and Search Console
7
8AnalyticsHQ provides separate imports because GA4 traffic and Search Console query performance have different schemas and credentials.
9
10## GA4 import
11
12Use the dashboard endpoint or the CLI to import a supported GA4 export:
13
14```bash
15bun run import:ga -- --site=SITE_ID --in=ga4-export.csv --dry-run
16```
17
18Review totals before writing. Historical aggregates cannot recreate the exact visitor identity of native AnalyticsHQ events.
19
20## Search Console import
21
22Search Console data adds queries, landing pages, clicks, impressions, click-through rate, and position to the site's search report.
23
24```bash
25bun run import:site -- --help
26bun scripts/analytics/import-search-console.ts --help
27```
28
29Use the script's current `--help` output as the authority for credentials and flags because Google export shapes can change.
30
31## Keep sources separate
32
33Imports are marked so they can be reconciled without overwriting native collection. Run a dry pass first, confirm the site identifier and date range, then write once.
docs/index.mdadded+31-0
Changes to docs/index.md
@@ -0,0 +1,31 @@
1---
2title: AnalyticsHQ Documentation
3description: Learn how to collect, understand, import, share, and operate privacy-first web analytics.
4layout: home
5hero:
6 name: AnalyticsHQ
7 text: Web analytics without surveillance
8 tagline: Own the data, understand the journey, and keep personal information out of the pipeline.
9 actions:
10 - theme: brand
11 text: Start tracking
12 link: /getting-started
13 - theme: alt
14 text: Read the privacy model
15 link: /privacy
16features:
17 - title: Privacy-first collection
18 details: No cookies, no persistent cross-day visitor identity, no raw IP storage, and no city tracking.
19 - title: Useful product analytics
20 details: Pages, sources, campaigns, events, goals, funnels, revenue, realtime traffic, and Web Vitals.
21 - title: Portable by design
22 details: Import from Fathom, GA4, and Search Console, or back up and restore a complete site archive.
23---
24
25## Choose a path
26
27- [Add AnalyticsHQ to a site](/tracking/install)
28- [Learn how reports and filters work](/reports/dashboard)
29- [Import existing analytics history](/imports/fathom)
30- [Run AnalyticsHQ on your own infrastructure](/self-hosting)
31- [Integrate with the HTTP API](/reference/api)
docs/introduction.mdadded+36-0
Changes to docs/introduction.md
@@ -0,0 +1,36 @@
1---
2title: What is AnalyticsHQ
3description: A practical overview of AnalyticsHQ and its privacy-first data model.
4---
5
6# What is AnalyticsHQ
7
8AnalyticsHQ is an open-source web analytics application. It collects a deliberately small event payload, stores it in your database, and turns it into reports for traffic, acquisition, behavior, conversions, performance, and revenue.
9
10It can run as the hosted service at `analyticshq.org` or as a self-hosted Stacks application.
11
12## What it measures
13
14The browser tracker records page paths, referrers, campaign parameters, device and browser families, country-level location by default, custom events, conversion values, and Core Web Vitals. The dashboard derives sessions, visitors, entry and exit pages, realtime activity, goals, and funnels from that data.
15
16## What it does not collect
17
18AnalyticsHQ does not set analytics cookies. It does not store raw IP addresses, page titles, screen dimensions, or city names. A visitor identifier is hashed with a site-specific daily salt, which prevents durable cross-day profiles.
19
20Region tracking is optional. The installation operator must permit it, the site owner must enable it, and the local geolocation database must contain subdivision data. City tracking has no supported path.
21
22## Main concepts
23
24| Concept | Meaning |
25| --- | --- |
26| Site | One tracked web property, identified by an App ID |
27| Page view | A visit to a path, enriched with privacy-safe dimensions |
28| Session | A sequence of activity within the configured inactivity window |
29| Custom event | A named action sent by the application |
30| Goal | A conversion rule based on a page or event |
31| Funnel | An ordered set of page or event steps |
32| Segment | A reusable collection of report filters |
33
34## Next steps
35
36Start with the [quick start](/getting-started), then read [install tracking](/tracking/install) for framework-specific setup.
docs/manage/teams-sharing.mdadded+26-0
Changes to docs/manage/teams-sharing.md
@@ -0,0 +1,26 @@
1---
2title: Teams and sharing
3description: Invite collaborators, publish summaries, and control site access.
4---
5
6# Teams and sharing
7
8## Site members
9
10The site owner can invite collaborators and remove existing members. Invitations are scoped to one site and accepted through the recipient's AnalyticsHQ account.
11
12The hosted Free plan permits no additional teammates. Pro removes that teammate cap. A self-hosted installation has no billing gate.
13
14## Public sharing
15
16A site can expose a read-only public summary through its share setting. Public widgets, badges, and sparklines use separate endpoints so a site can embed a small metric without granting dashboard access.
17
18Treat a share link as public information. Disable sharing when the intended audience changes.
19
20## Ownership
21
22Only the owner can change privacy-sensitive settings such as region collection, manage custom domains, delete the site's stored analytics, or delete the site itself.
23
24## Data deletion
25
26AnalyticsHQ supports deleting all data for a site and deleting a specific visitor's stored activity. These are destructive operations and should be used only after confirming the active site and identifier.
docs/privacy.mdadded+42-0
Changes to docs/privacy.md
@@ -0,0 +1,42 @@
1---
2title: Privacy model
3description: Understand AnalyticsHQ collection defaults, visitor hashing, geography, suppression, and retention.
4---
5
6# Privacy model
7
8Privacy behavior is declared centrally in `config/privacy.ts`. The defaults are part of the product contract, not generic toggles.
9
10## Default guarantees
11
12- Analytics cookies are not set.
13- Raw IP addresses are not stored.
14- Visitor hashes rotate with a site-specific daily salt.
15- Do Not Track and Global Privacy Control are respected.
16- Page titles and screen dimensions are not collected.
17- Geography is country-level unless a site explicitly opts into an installation that permits regions.
18- City data is not collected.
19
20## Visitor identity
21
22The collector uses request information only long enough to derive a daily, site-scoped hash. Salts are retained for two days so events around UTC midnight can be assigned consistently, then become unavailable.
23
24## Geography
25
26Location is resolved locally from a trusted CDN country header when present or from the configured DB-IP database. The IP is discarded after lookup.
27
28Region collection has three gates:
29
301. `geo.granularity` must permit `region`.
312. The site owner must enable `region_geo`.
323. The configured database must contain subdivision data.
33
34The default country database satisfies only country lookup. Small region rows are combined into `Other`.
35
36## Filter disclosure control
37
38Filtered reports with fewer than `ANALYTICSHQ_MIN_SEGMENT_SIZE` distinct visitors are refused. The default is 5. Set the value to `0` only for a deliberate private installation that accepts the disclosure tradeoff.
39
40## Retention
41
42`ANALYTICSHQ_RETENTION_DAYS` controls raw analytics retention. A value of `0` disables pruning. The scheduled prune script applies the policy to stored data.
docs/reference/api.mdadded+55-0
Changes to docs/reference/api.md
@@ -0,0 +1,55 @@
1---
2title: HTTP API
3description: A categorized reference to AnalyticsHQ collection, report, management, and public endpoints.
4---
5
6# HTTP API
7
8Dashboard APIs require the authenticated session unless described as public. Every site-scoped operation verifies membership or ownership on the server.
9
10## Collection
11
12| Method | Path | Purpose |
13| --- | --- | --- |
14| `POST` | `/collect` | Record page views, events, conversions, and vitals |
15| `GET` | `/script.js` | Browser tracker |
16| `GET` | `/api/health` | Service and geolocation health |
17
18## Sites and access
19
20| Methods | Path |
21| --- | --- |
22| `GET`, `POST` | `/api/sites` |
23| `PATCH`, `DELETE` | `/api/sites/{siteId}` |
24| `GET`, `POST` | `/api/sites/{siteId}/members` |
25| `DELETE` | `/api/sites/{siteId}/members/{userId}` |
26| `GET`, `POST` | `/api/sites/{siteId}/invites` |
27| `POST` | `/api/invites/accept` |
28
29## Reports
30
31Site report routes include `/stats`, `/timeseries`, `/pages`, `/entry-pages`, `/exit-pages`, `/referrers`, `/events`, `/realtime`, `/revenue`, `/vitals`, `/vitals-trends`, `/search`, and geographic and technology breakdowns.
32
33Common query parameters include the date range and filter dimensions. Use `GET /api/filters` to discover the current filter catalog.
34
35## Goals, funnels, and segments
36
37| Methods | Path |
38| --- | --- |
39| `GET`, `POST` | `/api/sites/{siteId}/goals` |
40| `DELETE` | `/api/sites/{siteId}/goals/{goalId}` |
41| `GET`, `POST` | `/api/sites/{siteId}/funnels` |
42| `PATCH`, `DELETE` | `/api/sites/{siteId}/funnels/{funnelId}` |
43| `GET` | `/api/sites/{siteId}/funnels/{funnelId}/results` |
44| `GET`, `POST` | `/api/sites/{siteId}/segments` |
45| `PATCH`, `DELETE` | `/api/sites/{siteId}/segments/{segmentId}` |
46
47## Imports and integrations
48
49Authenticated import endpoints exist for Fathom, GA4, and Search Console. Connector consumers can read the permitted field catalog and a bounded report through `/api/connect/{siteId}/fields` and `/api/connect/{siteId}/report`.
50
51## Public output
52
53Public summary, badge, and sparkline endpoints expose only sites whose sharing or widget setting permits it.
54
55For exact request fields and response shapes, consult `routes/analytics.ts`. It is the current wire-contract implementation.
docs/reference/cli.mdadded+38-0
Changes to docs/reference/cli.md
@@ -0,0 +1,38 @@
1---
2title: CLI reference
3description: Commands for developing, validating, importing, exporting, and operating AnalyticsHQ.
4---
5
6# CLI reference
7
8## Application commands
9
10| Command | Purpose |
11| --- | --- |
12| `bun run dev` | Start the local application and API |
13| `./buddy migrate` | Apply pending migrations |
14| `./buddy generate:migrations` | Generate model-driven schema changes |
15| `./buddy test` | Run the Bun test suite |
16| `bun run typecheck` | Run TypeScript checks |
17| `./buddy lint` | Run the configured linter |
18| `./buddy build docs` | Build this BunPress site |
19
20## Data movement
21
22| Command | Purpose |
23| --- | --- |
24| `bun run export:site -- ...` | Export NDJSON or one CSV table |
25| `bun run import:site -- ...` | Restore or clone an AnalyticsHQ archive |
26| `bun run import:fathom -- ...` | Backfill from the Fathom API |
27| `bun run import:ga -- ...` | Import a supported Google Analytics export |
28
29Pass `--help` to the underlying script before an operational run. Start imports with `--dry-run` when supported.
30
31## Documentation
32
33```bash
34./buddy dev docs
35./buddy build docs
36```
37
38The production build is written to `dist/docs/.bunpress`.
docs/reports/dashboard.mdadded+30-0
Changes to docs/reports/dashboard.md
@@ -0,0 +1,30 @@
1---
2title: Dashboard reports
3description: Understand the core AnalyticsHQ traffic and behavior reports.
4---
5
6# Dashboard reports
7
8Every dashboard report uses the active site, date range, and filter set. Selecting a row adds a filter so the rest of the page describes the same audience.
9
10## Overview
11
12The overview includes visitors, sessions, page views, pages per session, bounce rate, and duration where available. A timeseries shows change across the selected range.
13
14## Content
15
16Pages, entry pages, and exit pages explain what people viewed and where sessions began or ended. AnalyticsHQ stores paths rather than page titles.
17
18## Acquisition
19
20Referrers and campaign reports cover source, medium, campaign, term, and content values. Use consistent UTM naming so totals remain comparable.
21
22## Technology and geography
23
24Device, browser, operating system, and country are derived without storing raw user-agent strings or IP addresses. Region rows appear only when both the installation and the site have enabled region collection.
25
26Selecting the United States on the country map applies `country=US`; the region panel then narrows to US states automatically.
27
28## Realtime
29
30Realtime activity is a short current window, not a replacement for the selected historical range. Use it to validate a new installation and to watch launches.
docs/reports/filters-segments.mdadded+26-0
Changes to docs/reports/filters-segments.md
@@ -0,0 +1,26 @@
1---
2title: Filters and segments
3description: Narrow reports safely and save reusable audiences.
4---
5
6# Filters and segments
7
8Filters narrow every compatible report on the dashboard. Segments save a set of filters so it can be applied again.
9
10## Available dimensions
11
12AnalyticsHQ supports dimensions such as path, referrer, country, region when enabled, device, browser, operating system, campaign values, and event names. The API exposes the current filter catalog at `GET /api/filters`.
13
14## Combine filters
15
16Multiple filters describe their intersection. A country and device filter, for example, means visitors matching both values.
17
18## Privacy threshold
19
20Filtered reports are withheld when they describe fewer distinct visitors than `ANALYTICSHQ_MIN_SEGMENT_SIZE`, which defaults to 5. Unfiltered site totals are not suppressed. Low-volume region rows are combined into `Other` instead of exposing a tiny subdivision.
21
22This is expected behavior. It prevents several harmless-looking filters from identifying one visitor when composed.
23
24## Save a segment
25
26Create a segment from the current filters, give it a clear audience name, and reuse it in dashboards and funnels. Segment creation and editing require access to the site.
docs/reports/goals-funnels.mdadded+28-0
Changes to docs/reports/goals-funnels.md
@@ -0,0 +1,28 @@
1---
2title: Goals and funnels
3description: Measure conversions and ordered product journeys.
4---
5
6# Goals and funnels
7
8## Goals
9
10A goal marks a page or event as a successful outcome. Use page goals for stable destination paths and event goals for product actions that do not change the URL.
11
12Examples include:
13
14- `/signup/complete`
15- `Subscription started`
16- `Report exported`
17
18Goal reports count conversions within the active date range and filters.
19
20## Funnels
21
22A funnel is an ordered list of page and event steps. AnalyticsHQ reports how many visitors reached each step and where they left the sequence.
23
24Keep funnels focused. A short funnel with named product outcomes is easier to interpret than one that mirrors every screen.
25
26## Date ranges and segments
27
28Funnel results accept `startDate` and `endDate` and use the same filter model as the rest of AnalyticsHQ. A saved segment can narrow a funnel, subject to the minimum audience threshold described in [Filters and segments](/reports/filters-segments).
docs/reports/revenue-vitals.mdadded+28-0
Changes to docs/reports/revenue-vitals.md
@@ -0,0 +1,28 @@
1---
2title: Revenue and Web Vitals
3description: Connect conversion value with traffic and monitor user-facing performance.
4---
5
6# Revenue and Web Vitals
7
8## Revenue
9
10Revenue reports associate a numeric conversion value and currency with tracked outcomes. Use the smallest monetary unit consistently in the sending application, and never place payment credentials or customer details in analytics properties.
11
12Revenue is reported within the active site, range, and filters. It is intended for attribution and trend analysis, not accounting reconciliation.
13
14## Core Web Vitals
15
16AnalyticsHQ records five browser performance metrics:
17
18| Metric | Meaning |
19| --- | --- |
20| LCP | Largest Contentful Paint |
21| INP | Interaction to Next Paint |
22| CLS | Cumulative Layout Shift |
23| FCP | First Contentful Paint |
24| TTFB | Time to First Byte |
25
26Vitals are enabled by the installation policy and can be disabled for one site with `data-vitals="false"`. AnalyticsHQ stores the metric, numeric value, and path. It does not need a visitor's screen dimensions or page title.
27
28Use the trends report to find regressions across releases or periods. Browser measurements vary naturally, so compare distributions and trends rather than treating one sample as a verdict.
docs/self-hosting.mdadded+42-0
Changes to docs/self-hosting.md
@@ -0,0 +1,42 @@
1---
2title: Self-hosting
3description: Run AnalyticsHQ with Bun, Stacks, PostgreSQL, and optional local geolocation.
4---
5
6# Self-hosting
7
8## Requirements
9
10- Bun 1.3 or newer
11- PostgreSQL for concurrent ingest and report queries
12- SQLite 3.47.2 or newer for local development
13
14## Local setup
15
16```bash
17git clone https://github.com/stacksjs/analyticshq.git
18cd analyticshq
19bun install
20cp .env.example .env
21./buddy key:generate
22./buddy migrate
23bun run dev
24```
25
26The view server and API use separate local ports. Trust the URLs printed by the development command if you changed `config/ports.ts`.
27
28## Production configuration
29
30At minimum, configure the application URL and key, PostgreSQL connection, mail delivery, queue driver, and proxy behavior. Leave Stripe variables empty for a self-hosted installation; plan limits and hosted billing gates then remain inactive.
31
32## Geolocation database
33
34The deploy workflow can install the DB-IP Country Lite database. Set `ANALYTICSHQ_GEO_DB` to override its location. Region collection requires the City Lite database format and the installation's explicit region setting.
35
36## Processes
37
38Production serves the stx application and the API as separate processes behind one origin. The API owns `/collect` and `/api/*`; the application server renders pages and proxies those requests internally.
39
40## Health
41
42Monitor `GET /api/health`. It checks core service health and reports whether country and region geolocation are available.
docs/tracking/custom-domains.mdadded+31-0
Changes to docs/tracking/custom-domains.md
@@ -0,0 +1,31 @@
1---
2title: Custom domains
3description: Serve the tracker and collector from a verified first-party subdomain.
4---
5
6# Custom domains
7
8A custom domain lets a site load `script.js` and send `/collect` requests through a first-party hostname such as `stats.example.com`.
9
10## Configure DNS
11
121. Open the site settings in AnalyticsHQ.
132. Enter the analytics subdomain.
143. Add the displayed CNAME record at your DNS provider.
154. Return to AnalyticsHQ and verify the domain.
16
17AnalyticsHQ only emits a custom-domain snippet after verification succeeds. Before that, the dashboard continues to use the regular application origin so a newly installed tracker does not silently point at unready DNS.
18
19## Install the verified snippet
20
21```html
22<script defer src="https://stats.example.com/script.js" data-site="YOUR_APP_ID"></script>
23```
24
25The tracker sends data back to the origin that served it. No separate collector URL is required.
26
27## Operational notes
28
29- Keep the CNAME in place while the snippet is deployed.
30- Verify TLS before changing production sites.
31- Removing the domain in AnalyticsHQ returns future snippets to the normal origin, but existing tags must still be updated by the site owner.
docs/tracking/custom-events.mdadded+32-0
Changes to docs/tracking/custom-events.md
@@ -0,0 +1,32 @@
1---
2title: Custom events
3description: Record product actions and use them in goals and funnels.
4---
5
6# Custom events
7
8Custom events describe meaningful actions that are not page views, such as completing onboarding, starting checkout, or exporting a report.
9
10## Send an event
11
12After the tracker loads, call its event API with a stable event name and optional properties. Keep names short and product-oriented.
13
14```js
15window.tsAnalytics?.track('Signup completed', {
16 plan: 'pro',
17 source: 'pricing',
18})
19```
20
21Framework integrations expose the same behavior through their typed helpers. For example, the Nuxt module auto-imports `useTsAnalytics()` and the Vue plugin provides a typed `track()` surface.
22
23## Choose good event names
24
25- Describe the outcome, not the click target.
26- Keep the spelling and capitalization stable.
27- Do not put email addresses, names, tokens, or free-form customer content in event properties.
28- Use a property for a variant instead of creating a new event name for every variant.
29
30## Use events in reports
31
32Events appear in the dashboard event report. You can turn an event into a [goal](/reports/goals-funnels) or use multiple events and pages as ordered funnel steps.
docs/tracking/install.mdadded+99-0
Changes to docs/tracking/install.md
@@ -0,0 +1,99 @@
1---
2title: Install tracking
3description: Install the AnalyticsHQ tracker in HTML, Stacks, Nuxt, Vue, React, or Next.js.
4---
5
6# Install tracking
7
8The tracker should load once per document. Use the exact origin and App ID shown in the site dashboard.
9
10## Plain HTML
11
12```html
13<script defer src="https://analyticshq.org/script.js" data-site="YOUR_APP_ID"></script>
14```
15
16## Stacks and stx
17
18```bash
19bun add @ts-analytics/tracking
20```
21
22```ts
23import { tsAnalyticsStxConfig } from '@ts-analytics/tracking/stx'
24
25export default {
26 analytics: tsAnalyticsStxConfig({
27 appId: 'YOUR_APP_ID',
28 apiEndpoint: 'https://analyticshq.org',
29 }),
30}
31```
32
33Put the block in `config/ui.ts` for a Stacks app or `stx.config.ts` for a standalone stx site.
34
35## Nuxt
36
37```bash
38bun add @ts-analytics/tracking
39```
40
41```ts
42export default defineNuxtConfig({
43 modules: ['@ts-analytics/tracking/nuxt'],
44 tsAnalytics: {
45 appId: 'YOUR_APP_ID',
46 apiEndpoint: 'https://analyticshq.org',
47 },
48})
49```
50
51## Vue 3
52
53```ts
54import { tsAnalytics } from '@ts-analytics/tracking/vue'
55
56createApp(App)
57 .use(tsAnalytics, {
58 appId: 'YOUR_APP_ID',
59 apiEndpoint: 'https://analyticshq.org',
60 })
61 .mount('#app')
62```
63
64## React and Vite
65
66Put the plain script tag in `index.html`. Loading it from a component can attach tracking more than once during remounts.
67
68## Next.js
69
70Use `next/script` in `app/layout.tsx`:
71
72```tsx
73import Script from 'next/script'
74
75export default function RootLayout({ children }) {
76 return (
77 <html>
78 <body>
79 {children}
80 <Script
81 src="https://analyticshq.org/script.js"
82 data-site="YOUR_APP_ID"
83 strategy="afterInteractive"
84 />
85 </body>
86 </html>
87 )
88}
89```
90
91## Tracker options
92
93| Attribute | Purpose |
94| --- | --- |
95| `data-site` | Required App ID |
96| `data-respect-dnt="false"` | Allows the browser tracker to run when DNT is set; server-side GPC protection still applies |
97| `data-vitals="false"` | Disables Core Web Vitals for this site |
98
99The dashboard always shows a snippet based on the current host or verified custom domain. Prefer it over a copied example.
resources/views/dashboard.stxmodified+360-173
Changes to resources/views/dashboard.stx
@@ -73,11 +73,11 @@ const compare = queryParams.compare === '1' && !isAll
7373let prevFrom = new Date(new Date(from).getTime() - days * 864e5).toISOString()
7474
7575const ranges = [
76 { key: '24h', label: '24 hours' },
77 { key: '7d', label: '7 days' },
78 { key: '30d', label: '30 days' },
79 { key: '90d', label: '90 days' },
80 { key: '1y', label: '1 year' },
76 { key: '24h', label: '24h' },
77 { key: '7d', label: '7d' },
78 { key: '30d', label: '30d' },
79 { key: '90d', label: '90d' },
80 { key: '1y', label: '1y' },
8181 { key: 'all', label: 'All' },
8282]
8383
@@ -231,6 +231,25 @@ let live = false
231231// analyticshq_token cookie, which LoginAction sets server-side on sign-in (#33).
232232let user = null
233233let ownedSites = []
234let activeSiteRecord = null
235
236function siteDomains(site) {
237 if (!site?.domains)
238 return []
239 if (Array.isArray(site.domains))
240 return site.domains.map(String).filter(Boolean)
241 try {
242 const parsed = JSON.parse(String(site.domains))
243 return Array.isArray(parsed) ? parsed.map(String).filter(Boolean) : []
244 }
245 catch {
246 return []
247 }
248}
249
250function siteLabel(site) {
251 return siteDomains(site)[0] || site?.name || site?.id || 'Untitled site'
252}
234253// Read gate: the dashboard only exposes a site's data to someone this site is
235254// actually reachable by — its owner, an invited member, OR anyone holding a valid
236255// ?share= token. Stays false (→ no queries, no data in the response) for anyone else.
@@ -305,7 +324,7 @@ async function loadData() {
305324 // Mirrors routes/analytics.ts GET /api/sites deliberately: two answers to
306325 // "which sites can I reach" that disagree is what produced this.
307326 ownedSites = (await pgq(
308 `SELECT s.id, s.name, CASE WHEN s.owner_id = ? THEN 'owner' ELSE m.role END AS role
327 `SELECT s.id, s.name, s.domains, CASE WHEN s.owner_id = ? THEN 'owner' ELSE m.role END AS role
309328 FROM sites s
310329 LEFT JOIN site_members m ON m.site_id = s.id AND m.user_id = ?
311330 WHERE s.owner_id = ? OR m.user_id IS NOT NULL
@@ -359,6 +378,9 @@ async function loadData() {
359378 }
360379 if (!viewGranted)
361380 return
381 activeSiteRecord = ownedSites.find(s => String(s.id) === String(siteId))
382 || (await pgq(`SELECT id, name, domains FROM sites WHERE id = ? LIMIT 1`, [String(siteId)]))?.[0]
383 || null
362384 // Owner (not a share viewer): surface any existing share token for the link UI.
363385 if (!shareMode)
364386 shareToken = (await readSettings(siteId)).share_token || ''
@@ -1018,7 +1040,11 @@ const ownsActive = viewGranted && !shareMode
10181040// but cannot invite or remove. The endpoints enforce this independently; hiding
10191041// the controls only avoids offering a button that answers 403.
10201042const canManageTeam = ownsActive && (activeRole === 'owner' || activeRole === 'admin')
1021const showSiteSelect = ownsActive && ownedSites.length > 1
1043const showSiteSelect = ownsActive && ownedSites.length > 0
1044const activeSite = activeSiteRecord || ownedSites.find(s => String(s.id) === String(siteId)) || null
1045const activeSiteName = activeSite?.name || 'Shared site'
1046const activeSiteDomain = siteDomains(activeSite)[0] || ''
1047const activeSiteLabel = activeSiteDomain || activeSiteName
10221048
10231049// The install snippets, one per framework (app/Analytics/install.ts).
10241050//
@@ -1343,7 +1369,7 @@ async function importFromGa() {
13431369
13441370 for (let i = 0; i < chunks.length; i++) {
13451371 const [start, end] = chunks[i]
1346 gaProgress.set(`Importing ${start.slice(0, 4)} — period ${i + 1} of ${chunks.length}…`)
1372 gaProgress.set(`Importing ${start.slice(0, 4)}, period ${i + 1} of ${chunks.length}…`)
13471373 try {
13481374 const res = await session.authFetch(`/api/sites/${encodeURIComponent(activeSiteId)}/import/ga4`, {
13491375 method: 'POST',
@@ -1805,7 +1831,7 @@ async function revokeShare() {
18051831 // Worth surfacing rather than swallowing: a failed revoke means the old link is
18061832 // still live, and silently clearing nothing looks identical to success.
18071833 if (failed)
1808 await stxAlert('Could not revoke the share link. It may still be active — please try again.')
1834 await stxAlert('Could not revoke the share link. It may still be active. Please try again.')
18091835}
18101836// --- Team -------------------------------------------------------------------
18111837// The lists themselves are server-rendered (see the panel markup). These only
@@ -1869,7 +1895,7 @@ async function revokeInvite(id: string, email: string) {
18691895 }
18701896 }
18711897 catch {}
1872 await stxAlert('Could not revoke that invitation. It may still be usable — please try again.')
1898 await stxAlert('Could not revoke that invitation. It may still be usable. Please try again.')
18731899}
18741900
18751901async function removeMember(userId: string, email: string) {
@@ -1883,7 +1909,7 @@ async function removeMember(userId: string, email: string) {
18831909 }
18841910 }
18851911 catch {}
1886 await stxAlert('Could not remove that person. They may still have access — please try again.')
1912 await stxAlert('Could not remove that person. They may still have access. Please try again.')
18871913}
18881914
18891915// Created ONCE, not per click. useTimeout starts on creation and registers an
@@ -2038,27 +2064,188 @@ onMount(() => {
20382064.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; }
20392065.pill { border-radius: 999px; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; }
20402066
2041/* Site switcher. The native chevron sat flush against the control's own rounded edge,
2042 so the appearance is reset and the chevron redrawn as a background SVG with room
2043 reserved for it (padding-right). Surface, border and radius come from .panel, which is
2044 why the select is a panel and not a pill here — 999px is what made the native chevron
2045 collide. None of this reaches the open dropdown: the list itself is drawn by the OS and
2046 is not stylable, which is why the fix for it landing on top of "+ Add site" is the row
2047 layout below, not anything here.
2048 The chevron is a data URI, so its colour cannot inherit: it is restated per theme,
2049 media query first so the explicit [data-theme] toggle wins. */
2050select.panel {
2051 -webkit-appearance: none; -moz-appearance: none; appearance: none;
2052 cursor: pointer; transition: border-color 0.15s ease;
2053 padding-right: 2rem; background-repeat: no-repeat;
2054 background-position: right 0.7rem center; background-size: 13px;
2055 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2398a2b3' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
2067.workspace-header {
2068 border-bottom: 1px solid var(--border);
2069}
2070.workspace-primary,
2071.workspace-range {
2072 display: flex;
2073 align-items: center;
2074 justify-content: space-between;
2075 gap: 1rem;
2076}
2077.workspace-primary { padding: 0 0 1.1rem; }
2078.workspace-range { padding: 0.85rem 0; }
2079.workspace-brand,
2080.workspace-actions,
2081.range-presets,
2082.range-actions {
2083 display: flex;
2084 align-items: center;
2085 gap: 0.5rem;
2086}
2087.workspace-brand { min-width: 0; gap: 0.9rem; }
2088.workspace-actions { flex-shrink: 0; }
2089.site-switcher {
2090 min-width: 11rem;
2091 max-width: 18rem;
2092 height: 2.25rem;
2093 padding: 0 0.75rem;
2094 font-size: 0.875rem;
2095 font-weight: 500;
2096}
2097.site-static {
2098 max-width: 18rem;
2099 overflow: hidden;
2100 text-overflow: ellipsis;
2101 white-space: nowrap;
2102 color: var(--text-2);
2103 font: 500 0.875rem/1 var(--sans, ui-sans-serif);
2104}
2105.range-presets {
2106 gap: 0;
2107 padding: 0.2rem;
2108 border: 1px solid var(--border);
2109 border-radius: 10px;
2110 background: var(--panel);
2111}
2112.range-link {
2113 min-width: 2.75rem;
2114 padding: 0.48rem 0.7rem;
2115 border-radius: 7px;
2116 color: var(--text-2);
2117 font: 600 0.8125rem/1 var(--sans, ui-sans-serif);
2118 text-align: center;
2119 text-decoration: none;
2120 transition: color 0.15s ease, background-color 0.15s ease;
2121}
2122.range-link:hover { color: var(--text); }
2123.range-link[aria-current="page"] {
2124 background: var(--accent);
2125 color: #fff;
2126}
2127.range-tool {
2128 display: inline-flex;
2129 align-items: center;
2130 justify-content: center;
2131 gap: 0.4rem;
2132 min-height: 2.25rem;
2133 padding: 0 0.72rem;
2134 border: 1px solid var(--border);
2135 border-radius: 9px;
2136 background: var(--panel);
2137 color: var(--text-2);
2138 font: 600 0.8125rem/1 var(--sans, ui-sans-serif);
2139 text-decoration: none;
2140 cursor: pointer;
2141 transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
2142}
2143.range-tool:hover { color: var(--text); border-color: var(--accent); }
2144.range-tool:active { transform: scale(0.98); }
2145.range-tool:focus-visible,
2146.range-link:focus-visible,
2147.chrome-btn:focus-visible,
2148.icon-btn:focus-visible,
2149select.panel:focus-visible,
2150input:focus-visible,
2151textarea:focus-visible,
2152summary:focus-visible {
2153 outline: 2px solid var(--accent);
2154 outline-offset: 2px;
2155}
2156.range-tool.is-active { color: #fff; background: var(--accent); border-color: var(--accent); }
2157.range-tool i { width: 1rem; height: 1rem; }
2158.date-menu { position: relative; }
2159.date-menu > summary { list-style: none; }
2160.date-menu > summary::-webkit-details-marker { display: none; }
2161.date-popover {
2162 position: absolute;
2163 right: 0;
2164 top: calc(100% + 0.5rem);
2165 z-index: 40;
2166 width: min(24rem, calc(100vw - 2.5rem));
2167 padding: 1rem;
2168 border: 1px solid var(--border);
2169 border-radius: 12px;
2170 background: var(--panel);
2171 box-shadow: 0 18px 50px color-mix(in srgb, var(--text) 10%, transparent);
2172}
2173.date-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
2174.date-field label {
2175 display: block;
2176 margin-bottom: 0.35rem;
2177 color: var(--text-2);
2178 font: 600 0.75rem/1.2 var(--sans, ui-sans-serif);
20562179}
2057@media (prefers-color-scheme: dark) {
2058 select.panel { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); }
2180.date-field input {
2181 width: 100%;
2182 padding: 0.55rem 0.65rem;
2183 border: 1px solid var(--border);
2184 border-radius: 8px;
2185 background: var(--bg);
2186 color: var(--text);
2187 font: 500 0.8125rem/1 var(--mono, ui-monospace);
2188}
2189.date-submit { width: 100%; margin-top: 0.75rem; }
2190.live-status {
2191 display: inline-flex;
2192 align-items: center;
2193 gap: 0.45rem;
2194 min-height: 2.25rem;
2195 padding: 0 0.7rem;
2196 border: 1px solid var(--border);
2197 border-radius: 9px;
2198 color: var(--text-2);
2199}
2200.live-status strong { color: var(--text); font-size: 0.75rem; }
2201.live-status span:last-child { font-size: 0.75rem; }
2202.site-settings-shell > summary {
2203 display: flex;
2204 align-items: center;
2205 justify-content: space-between;
2206 gap: 1rem;
2207 width: 100%;
2208 padding: 0.9rem 1rem;
2209 border: 0;
2210 border-radius: inherit;
2211 background: transparent;
2212 color: var(--text);
2213 cursor: pointer;
2214 text-align: left;
2215 list-style: none;
2216}
2217.site-settings-shell > summary::-webkit-details-marker { display: none; }
2218.site-settings-title { display: flex; align-items: center; gap: 0.7rem; min-width: 0; }
2219.site-settings-title i { width: 1.1rem; height: 1.1rem; color: var(--text-2); }
2220.site-settings-title strong { display: block; font-size: 0.875rem; line-height: 1.25; }
2221.site-settings-title span { display: block; margin-top: 0.18rem; color: var(--text-3); font-size: 0.75rem; line-height: 1.35; }
2222.settings-chevron { width: 1rem; height: 1rem; flex-shrink: 0; color: var(--text-3); transition: transform 0.15s ease; }
2223.site-settings-shell[open] .settings-chevron { transform: rotate(180deg); }
2224.site-settings-shell:not([open]) ~ .site-settings-panel { display: none; }
2225.site-settings-panel { margin-bottom: 0.75rem; }
2226
2227@media (max-width: 1023px) {
2228 .workspace-primary,
2229 .workspace-range { align-items: stretch; flex-direction: column; }
2230 .workspace-brand,
2231 .workspace-actions,
2232 .range-actions { width: 100%; flex-wrap: wrap; }
2233 .workspace-actions { justify-content: flex-start; }
2234 .site-switcher { min-width: 0; max-width: none; flex: 1; }
2235 .range-presets { width: 100%; overflow-x: auto; }
2236 .range-link { flex: 1 0 auto; }
2237 .range-actions { justify-content: flex-start; }
2238 .date-menu { position: static; }
2239 .date-popover { left: 0; right: auto; }
2240}
2241
2242/* Site switcher. Surface, border and radius come from .panel. The native menu remains
2243 an operating-system control, while the surrounding shell gives it enough room to
2244 avoid colliding with adjacent actions. */
2245select.panel {
2246 cursor: pointer;
2247 transition: border-color 0.15s ease;
20592248}
2060:root[data-theme="dark"] select.panel { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); }
2061:root[data-theme="light"] select.panel { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2398a2b3' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); }
20622249select.panel:hover { border-color: var(--accent); }
20632250
20642251/* Map tooltips, themed. ts-maps binds and positions these itself, but only its
@@ -2161,116 +2348,98 @@ select.panel:hover { border-color: var(--accent); }
21612348 LAST </main>, so a page that declares its own makes the router swap the wrong span.
21622349 Same classes as before, one tag name different. --}}
21632350@section('content')
2164<div class="px-5 py-10 md:px-10 min-h-[100dvh]">
2165 {{-- Account chrome (fixed top-right). Owner-only (ownsActive) — a read-only share
2166 viewer isn't signed in as the owner.
2167
2168 Three controls, and deliberately no plan CTA. There used to be an Upgrade
2169 button here, which was duplication: /account already carries a Plan section
2170 showing Free or Pro, an active badge, and the same /pricing link as either
2171 "Upgrade to Pro" or "Manage plan". Billing belongs with the account, not
2172 pinned to every view of the numbers.
2173
2174 The route to /account is the point of this block. That page has existed and
2175 answered 200 all along with nothing anywhere linking to it, so the only way
2176 in was to type the URL.
2177
2178 isPro() is still read further down for the team-panel paywall — dropping the
2179 button here did not orphan it. --}}
2180 @if (ownsActive)
2181 <div style="position:fixed;top:16px;right:18px;z-index:60;display:flex;gap:6px;align-items:center">
2182 <StxLink to="/account" class="chrome-btn">Account</StxLink>
2183 <button type="button" @click="logout" class="chrome-btn">Log out</button>
2184 </div>
2185 @endif
2186 <div class="mx-auto max-w-6xl">
2187
2188 <header class="flex flex-wrap gap-5 items-end justify-between mb-9">
2189 <div>
2190 <h1 class="leading-none text-2xl md:text-[28px] wordmark">analyticshq</h1>
2191 <p class="mt-2 text-sm text-text-3">
2192 Site <span class="text-text-2 mono">{{ siteId }}</span> · {{ isAll ? 'all time' : ('last ' + days + ' days') }}
2193 </p>
2194 {{-- Site switcher (server-rendered, owner-scoped — mirrors bughq): the
2195 <select> hands off to switchSite, which routes to ?site=; the caption line
2196 above is the no-owned-sites fallback. "+ Add site" is a link to the
2197 reactive create page, not an in-page modal. --}}
2198 {{-- The multi-site <select> is gated by a precomputed `showSiteSelect` flag as
2199 its own block, and "+ Add site" always shows to the owner. This was
2200 originally a workaround for stx miscounting @endif on a nested @if; that
2201 bug is fixed as of 0.2.155 (verified on the installed version), so the flag
2202 stays only because it reads better than nesting here. --}}
2203 {{-- One row, like bughq's header: the switcher, "+ Add site" and the live badge
2204 were three sibling `mt-3` blocks in a plain <div>, so each got its own line
2205 and the three read as a ragged column. They are peers, so they share one
2206 flex row and one size scale (px-3 py-1.5, .panel/.pill chrome from classes
2207 rather than per-control inline style). The badge keeps its own wrapper: its
2208 spans render reactive {{ }} text, and stx cloaks (display:none) the element
2209 whose text is reactive until hydration — folding it into the row itself
2210 would put the switcher inside that scope. --}}
2211 <div class="flex flex-wrap gap-3 items-center mt-3">
2351<div class="px-4 py-7 md:px-8 lg:px-10 min-h-[100dvh]">
2352 <div class="mx-auto max-w-[1400px]">
2353
2354 <header class="mb-6 workspace-header">
2355 <div class="workspace-primary">
2356 <div class="workspace-brand">
2357 <h1 class="leading-none text-2xl md:text-[28px] wordmark">analyticshq</h1>
22122358 @if (showSiteSelect)
2213 <select @change="switchSite"
2214 aria-label="Switch site"
2215 class="px-3 py-1.5 text-sm text-text-2 mono panel">
2359 <select @change="switchSite" aria-label="Switch site" class="panel site-switcher">
22162360 @foreach (ownedSites as s)
2217 <option value="" {{ String(s.id) === String(siteId) ? 'selected' : '' }}>{{ s.name || s.id }}</option>
2361 <option value="" {{ String(s.id) === String(siteId) ? 'selected' : '' }}>{{ siteLabel(s) }}</option>
22182362 @endforeach
22192363 </select>
2364 @else
2365 <span class="site-static" title="">{{ activeSiteLabel }}</span>
22202366 @endif
2367 </div>
2368
2369 <div class="workspace-actions">
2370 <div class="live-status reveal" aria-label="Current visitors">
2371 <span :class="liveCount() ? 'live-dot' : ''" :style="`position: relative; display: inline-block; width: 7px; height: 7px; border-radius: 999px; background: ${liveCount() ? 'var(--accent)' : 'var(--text-3)'};`"></span>
2372 <strong class="mono">{{ liveCount() }}</strong>
2373 <span>live</span>
2374 </div>
22212375 @if (ownsActive)
2222 <StxLink to="/sites/new" class="px-3 py-1.5 font-medium text-sm text-text-2 border border-border pill">+ Add site</StxLink>
2376 <StxLink to="/sites/new" class="range-tool">
2377 <i class="i-hugeicons-add-01" aria-hidden="true"></i>
2378 New site
2379 </StxLink>
2380 <a href="#site-settings" data-no-router class="range-tool" title="Site settings">
2381 <i class="i-hugeicons-settings-02" aria-hidden="true"></i>
2382 <span class="sr-only">Site settings</span>
2383 </a>
22232384 @endif
2224 {{-- The theme toggle sits in this row rather than in a fixed bar over the
2225 page. It used to be `position: fixed; top: 16px; right: 18px`, which
2226 floated it above whatever was underneath -- on this page that is the
2227 map, and the button landed on top of it. In the row it is laid out
2228 with its peers and cannot overlap anything.
2229
2230 SVG rather than the ☾/☀ glyphs it used to draw: those render at
2231 whatever the emoji font decides, so the button's height moved between
2232 platforms while the pills beside it did not. --}}
2233 <button type="button" @click="toggleTheme" :data-t="theme.isDark() ? 'dark' : 'light'"
2234 class="icon-btn theme-btn" aria-label="Toggle light or dark theme" title="Toggle theme">
2235 <svg class="ic-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
2236 <svg class="ic-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
2385 <button type="button" @click="toggleTheme" :data-t="theme.isDark() ? 'dark' : 'light'" class="range-tool theme-btn" aria-label="Toggle light or dark theme" title="Toggle theme">
2386 <i class="i-hugeicons-sun-03 ic-sun" aria-hidden="true"></i>
2387 <i class="i-hugeicons-moon-02 ic-moon" aria-hidden="true"></i>
2388 </button>
2389 @if (ownsActive)
2390 <StxLink to="/account" class="chrome-btn">Account</StxLink>
2391 <button type="button" @click="logout" class="chrome-btn">Log out</button>
2392 @endif
2393 </div>
2394 </div>
2395
2396 <div class="workspace-range">
2397 <nav class="range-presets" aria-label="Reporting period">
2398 @foreach (ranges as r)
2399 <StxLink
2400 to="?site=&range="
2401 class="range-link"
2402 aria-current=""
2403 >{{ r.label }}</StxLink>
2404 @endforeach
2405 </nav>
2406
2407 <div class="range-actions">
2408 <details class="date-menu">
2409 <summary class="range-tool ">
2410 <i class="i-hugeicons-calendar-03" aria-hidden="true"></i>
2411 {{ isCustom ? (customFrom + ' to ' + customTo) : 'Custom' }}
2412 </summary>
2413 <form method="get" class="date-popover" aria-label="Custom date range">
2414 <input type="hidden" name="site" value="">
2415 @if (compare)
2416 <input type="hidden" name="compare" value="1">
2417 @endif
2418 <div class="date-grid">
2419 <div class="date-field">
2420 <label for="range-from">From</label>
2421 <input id="range-from" type="date" name="from" value="">
2422 </div>
2423 <div class="date-field">
2424 <label for="range-to">To</label>
2425 <input id="range-to" type="date" name="to" value="">
2426 </div>
2427 </div>
2428 <button type="submit" class="date-submit is-active range-tool">Apply range</button>
2429 </form>
2430 </details>
2431 <StxLink
2432 to=""
2433 title="Overlay the previous period on the chart"
2434 class="range-tool "
2435 style=""
2436 >Compare</StxLink>
2437 <button type="button" @click="exportCsv" class="range-tool">
2438 <i class="i-hugeicons-download-04" aria-hidden="true"></i>
2439 Export
22372440 </button>
2238 <div class="inline-flex gap-2 items-center px-3 py-2 border border-border pill reveal">
2239 <span :class="liveCount() ? 'live-dot' : ''" :style="`position: relative; display: inline-block; width: 7px; height: 7px; border-radius: 999px; background: ${liveCount() ? 'var(--accent)' : 'var(--text-3)'};`"></span>
2240 <span class="font-semibold text-xs mono" :style="`color: ${liveCount() ? 'var(--text)' : 'var(--text-3)'}`">{{ liveCount() }}</span>
2241 <span class="text-text-3 text-xs">current {{ liveCount() === 1 ? 'visitor' : 'visitors' }}</span>
2242 </div>
22432441 </div>
22442442 </div>
2245 {{-- Wraps, unlike before: the left column is now one row rather than a stack, so
2246 the two header halves compete for the same line and this nav carries eleven
2247 controls with long range labels ("24 hours", not "24h"). --}}
2248 <nav class="flex flex-wrap gap-1.5 items-center">
2249 @foreach (ranges as r)
2250 <StxLink to="?site=&range="
2251 class="px-3.5 py-1.5 font-medium text-sm border pill"
2252 style="">{{ r.label }}</StxLink>
2253 @endforeach
2254 <form method="get" class="inline-flex gap-1.5 items-center" title="Custom date range">
2255 <input type="hidden" name="site" value="">
2256 @if (compare)
2257 <input type="hidden" name="compare" value="1">
2258 @endif
2259 <input type="date" name="from" value="" aria-label="From date"
2260 class="px-2 py-1.5 text-sm border pill" style="color: var(--text-2); border-color: var(--border); background: var(--panel);">
2261 <span class="text-text-3 text-xs">to</span>
2262 <input type="date" name="to" value="" aria-label="To date"
2263 class="px-2 py-1.5 text-sm border pill" style="color: var(--text-2); border-color: var(--border); background: var(--panel);">
2264 <button type="submit" class="px-3.5 py-1.5 font-medium text-sm border pill"
2265 style="color: var(--text-2); border-color: var(--border); background: var(--panel); cursor: pointer;">Apply</button>
2266 </form>
2267 <StxLink to="" title="Overlay the previous period on the chart"
2268 class="px-3.5 py-1.5 font-medium text-sm border pill"
2269 style="color: var(--text-2); border-color: var(--border)">Compare</StxLink>
2270 <button type="button" @click="exportCsv"
2271 class="px-3.5 py-1.5 font-medium text-sm border pill"
2272 style="color: var(--text-2); border-color: var(--border); background: var(--panel); cursor: pointer;">Export CSV</button>
2273 </nav>
22742443 </header>
22752444
22762445 {{-- Active filters: click a breakdown row to add one; each pill removes itself,
@@ -2295,6 +2464,39 @@ select.panel:hover { border-color: var(--accent); }
22952464 </div>
22962465 @endif
22972466
2467 @if (viewGranted)
2468 {{-- The numbers lead the workspace. Setup and account controls stay available,
2469 but they no longer push the report below the first viewport. --}}
2470 <section class="grid grid-cols-2 lg:grid-cols-7 mb-6 border-border lg:divide-x panel reveal" aria-label="Key metrics">
2471 @foreach (kpiCards as kpi)
2472 <div class="px-5 py-5 border-border">
2473 <div class="mb-2 font-medium text-text-3 text-xs tracking-wider uppercase">{{ kpi.label }}</div>
2474 <div class="font-semibold text-3xl tracking-tight mono">{{ kpi.value }}</div>
2475 @if (kpi.delta !== null)
2476 <div class="mt-1.5 font-medium text-xs mono" style="color: ">
2477 {{ kpi.delta >= 0 ? '▲' : '▼' }} {{ Math.abs(kpi.delta) }}% <span class="text-text-3">vs prev</span>
2478 </div>
2479 @endif
2480 </div>
2481 @endforeach
2482 </section>
2483 @endif
2484
2485 @if (ownsActive)
2486 <details id="site-settings" class="mb-3 panel reveal site-settings-shell">
2487 <summary>
2488 <span class="site-settings-title">
2489 <i class="i-hugeicons-settings-02" aria-hidden="true"></i>
2490 <span>
2491 <strong>Site settings</strong>
2492 <span>Tracking, team access, imports, privacy, and sharing</span>
2493 </span>
2494 </span>
2495 <i class="i-hugeicons-arrow-down-01 settings-chevron" aria-hidden="true"></i>
2496 </summary>
2497 </details>
2498 @endif
2499
22982500 {{-- Setup panel: shown to the site's owner so they can grab the install
22992501 snippet for whichever framework they run. Open by default until the first
23002502 pageview lands.
@@ -2304,11 +2506,11 @@ select.panel:hover { border-color: var(--accent); }
23042506 installCode() note in the client block. {{ }} HTML-escapes the snippet so
23052507 the tag shows as text rather than executing. --}}
23062508 @if (ownsActive)
2307 <details class="mb-8 border-border panel reveal" {{ Number(kpis.views) ? '' : 'open' }}>
2308 <summary class="flex items-center justify-between px-5 py-4" style="cursor: pointer">
2509 <section class="border-border panel site-settings-panel">
2510 <div class="flex items-center justify-between px-5 py-4">
23092511 <span class="font-semibold text-sm">Install analyticshq on this site</span>
23102512 <span class="px-2 py-0.5 text-[11px] rounded-full mono" style="border: 1px solid var(--border); color: var(--text-3)">Snippet</span>
2311 </summary>
2513 </div>
23122514 <div class="pb-5 px-5">
23132515 {{-- Tabs and prose are SERVER-rendered (@foreach, static labels, server
23142516 mustaches) so this panel still says something useful before the
@@ -2343,7 +2545,7 @@ select.panel:hover { border-color: var(--accent); }
23432545 answer here and the server cannot know it. --}}
23442546 <pre class="px-4 py-3 text-xs mono" style="white-space: pre; overflow-x: auto; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); color: var(--text-2); margin: 0">{{ installCode() }}</pre>
23452547 </div>
2346 </details>
2548 </section>
23472549 @endif
23482550
23492551 {{-- Team. Everyone who can reach this site, plus anyone invited who has not
@@ -2359,11 +2561,11 @@ select.panel:hover { border-color: var(--accent); }
23592561 Visible to any member (a viewer should be able to tell how widely a site
23602562 is shared); only admin and owner get the controls. --}}
23612563 @if (ownsActive)
2362 <details class="mb-8 border-border panel reveal">
2363 <summary class="flex items-center justify-between px-5 py-4" style="cursor: pointer">
2564 <section class="border-border panel site-settings-panel">
2565 <div class="flex items-center justify-between px-5 py-4">
23642566 <span class="font-semibold text-sm">Team</span>
23652567 <span class="px-2 py-0.5 text-[11px] rounded-full mono" style="border: 1px solid var(--border); color: var(--text-3)">{{ teamMembers.length }} with access</span>
2366 </summary>
2568 </div>
23672569 <div class="pb-5 px-5">
23682570
23692571 @foreach (teamMembers as m)
@@ -2440,7 +2642,7 @@ select.panel:hover { border-color: var(--accent); }
24402642 @endif
24412643
24422644 </div>
2443 </details>
2645 </section>
24442646 @endif
24452647
24462648 {{-- Import from Google Analytics. Owner-only: it writes history into this
@@ -2454,14 +2656,14 @@ select.panel:hover { border-color: var(--accent); }
24542656 conditional parts use :show with their SSR state in the static style
24552657 (stx-standards §7.1/§7.3), as the share control above does. --}}
24562658 @if (ownsActive)
2457 <details class="mb-8 border-border panel reveal">
2458 <summary class="flex items-center justify-between px-5 py-4" style="cursor: pointer">
2659 <section class="border-border panel site-settings-panel">
2660 <div class="flex items-center justify-between px-5 py-4">
24592661 <span class="font-semibold text-sm">Import from Google Analytics</span>
24602662 <span class="px-2 py-0.5 text-[11px] rounded-full mono" style="border: 1px solid var(--border); color: var(--text-3)">GA4</span>
2461 </summary>
2663 </div>
24622664 <div class="pb-5 px-5">
24632665 <p class="mb-3 text-sm text-text-2">
2464 Brings your GA4 history over directly — no CSV export. In the Google Cloud project that owns the property:
2666 Brings your GA4 history over directly, with no CSV export. In the Google Cloud project that owns the property:
24652667 create a service account, download its JSON key, enable the Analytics Data API, then add that service
24662668 account's email as a <span class="font-medium">Viewer</span> under GA4 → Admin → Property access management.
24672669 </p>
@@ -2510,7 +2712,7 @@ select.panel:hover { border-color: var(--accent); }
25102712 Re-importing the same period replaces it rather than doubling it.
25112713 </p>
25122714 </div>
2513 </details>
2715 </section>
25142716 @endif
25152717
25162718 {{-- Import from Fathom. Owner-only for the same reason as the GA panel, and
@@ -2529,11 +2731,11 @@ select.panel:hover { border-color: var(--accent); }
25292731 :dragover, which the runtime does not recognise as events and would
25302732 quietly turn into DOM attributes that never fire. --}}
25312733 @if (ownsActive)
2532 <details class="mb-8 border-border panel reveal">
2533 <summary class="flex items-center justify-between px-5 py-4" style="cursor: pointer">
2734 <section class="border-border panel site-settings-panel">
2735 <div class="flex items-center justify-between px-5 py-4">
25342736 <span class="font-semibold text-sm">Import from Fathom</span>
25352737 <span class="px-2 py-0.5 text-[11px] rounded-full mono" style="border: 1px solid var(--border); color: var(--text-3)">CSV</span>
2536 </summary>
2738 </div>
25372739 <div class="pb-5 px-5">
25382740 @if (canImportCsv)
25392741 <p class="mb-3 text-sm text-text-2">
@@ -2609,13 +2811,13 @@ select.panel:hover { border-color: var(--accent); }
26092811 </div>
26102812 @endif
26112813 </div>
2612 </details>
2814 </section>
26132815 @endif
26142816
26152817 @if (shareMode)
26162818 <div class="mb-6 px-4 py-3 text-sm panel reveal" style="border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); color: var(--text-2)">
26172819 <span class="font-semibold text-text">Shared read-only view.</span>
2618 You're viewing a shared dashboard — data only, no sign-in needed.
2820 This link shows report data only. Sign-in and management controls are unavailable.
26192821 </div>
26202822 @endif
26212823
@@ -2623,7 +2825,7 @@ select.panel:hover { border-color: var(--accent); }
26232825
26242826 Two server @ifs and no client @if, for the usual reason: a client @if
26252827 nested inside a server one breaks stx's matching. The instance
2626 branch is server-side because config/privacy.ts is a server value — an
2828 branch is server-side because config/privacy.ts is a server value, so an
26272829 install that does not permit regions shows the reason rather than a
26282830 switch the endpoint would answer 409 to.
26292831
@@ -2632,7 +2834,7 @@ select.panel:hover { border-color: var(--accent); }
26322834 optimistic tick that then failed would leave the box saying yes about a
26332835 site recording nothing. --}}
26342836 @if (ownsActive)
2635 <div class="mb-8 p-5 panel reveal">
2837 <div class="p-5 panel site-settings-panel">
26362838 <div class="flex flex-wrap gap-3 items-start justify-between">
26372839 <div style="max-width:60ch">
26382840 <div class="font-semibold text-sm">Record state and province</div>
@@ -2666,15 +2868,15 @@ select.panel:hover { border-color: var(--accent); }
26662868 Signals live in the <script client> block; shareUrl is seeded from the
26672869 server-bridged existing token so the current link shows on load. --}}
26682870 @if (ownsActive)
2669 <div class="mb-8 p-5 panel reveal">
2871 <div class="p-5 panel site-settings-panel">
26702872 <div class="flex flex-wrap gap-3 items-center justify-between">
26712873 <div>
26722874 <div class="font-semibold text-sm">Share this dashboard</div>
2673 <div class="mt-0.5 text-text-3 text-xs">A read-only link — anyone with it can view, but not manage, this site.</div>
2875 <div class="mt-0.5 text-text-3 text-xs">Anyone with this read-only link can view the report, but cannot manage the site.</div>
26742876 </div>
26752877 {{-- No @if here: a client-signal @if nested inside a server @if breaks stx's
26762878 @endif matching. :show is the binding for this (stx-standards §7.3) and
2677 carries its SSR state in the static style, per §7.1 — the element ships
2879 carries its SSR state in the static style, per §7.1. The element ships
26782880 hidden and the first effect reveals it. --}}
26792881 <div class="flex flex-wrap gap-2 items-center" :show="shareUrl()" style="display:none">
26802882 <input readonly :value="shareUrl()" aria-label="Share link" class="text-xs mono" style="min-width:220px;max-width:340px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text-2)">
@@ -2691,21 +2893,6 @@ select.panel:hover { border-color: var(--accent); }
26912893 (below, @else) instead of the panels. --}}
26922894 @if (viewGranted)
26932895
2694 {{-- KPIs: hairline-separated, mono numerics, no heavy card chrome --}}
2695 <div class="grid grid-cols-2 lg:grid-cols-7 mb-6 border-border lg:divide-x panel reveal">
2696 @foreach (kpiCards as kpi)
2697 <div class="px-5 py-5 border-border">
2698 <div class="mb-2 font-medium text-text-3 text-xs tracking-wider uppercase">{{ kpi.label }}</div>
2699 <div class="font-semibold text-3xl tracking-tight mono">{{ kpi.value }}</div>
2700 @if (kpi.delta !== null)
2701 <div class="mt-1.5 font-medium text-xs mono" style="color: ">
2702 {{ kpi.delta >= 0 ? '▲' : '▼' }} {{ Math.abs(kpi.delta) }}% <span class="text-text-3">vs prev</span>
2703 </div>
2704 @endif
2705 </div>
2706 @endforeach
2707 </div>
2708
27092896 {{-- Timeseries: page views (area) + unique visitors (line) --}}
27102897 <div class="mb-6 p-5 panel reveal">
27112898 <div class="flex flex-wrap gap-3 items-center justify-between mb-5">
@@ -2773,7 +2960,7 @@ select.panel:hover { border-color: var(--accent); }
27732960 <div class="absolute inset-y-1 left-0 rounded" style="width: %; background: var(--bar);"></div>
27742961 <span class="relative pr-2 text-text-2 truncate ga-row-label mono">{{ p.path }}</span>
27752962 <span class="flex relative gap-3.5 items-baseline">
2776 <span class="tabular-nums text-text-3 mono" title="Bounce rate — of sessions that landed on this page, the share that bounced">{{ Number(p.entries) ? `${Math.round((Number(p.bounces) / Number(p.entries)) * 100)}%` : '—' }}</span>
2963 <span class="tabular-nums text-text-3 mono" title="Bounce rate for sessions that landed on this page">{{ Number(p.entries) ? `${Math.round((Number(p.bounces) / Number(p.entries)) * 100)}%` : 'n/a' }}</span>
27772964 <span class="font-medium tabular-nums mono">{{ Number(p.views).toLocaleString() }}</span>
27782965 </span>
27792966 </StxLink>
@@ -2870,7 +3057,7 @@ select.panel:hover { border-color: var(--accent); }
28703057 it is. Splitting by device divides the sample count,
28713058 so this is common on small sites and has to read as
28723059 "not enough people" rather than "broken". --}}
2873 <span class="text-text-3" title="Withheld — NaN of measurements needed">—</span>
3060 <span class="text-text-3" title="Withheld: NaN of measurements needed">n/a</span>
28743061 @else
28753062 <span class="text-text-3" title="No measurements yet">·</span>
28763063 @endif
@@ -2924,8 +3111,8 @@ select.panel:hover { border-color: var(--accent); }
29243111 <td class="py-2 tabular-nums text-right mono">{{ Number(q.impressions).toLocaleString() }}</td>
29253112 {{-- Derived here from the two stored counts, never read from a
29263113 ctr column -- see the migration for why one is not stored. --}}
2927 <td class="py-2 tabular-nums text-right mono">{{ Number(q.impressions) > 0 ? `${(Number(q.clicks) / Number(q.impressions) * 100).toFixed(1)}%` : '—' }}</td>
2928 <td class="pr-1 py-2 tabular-nums text-right mono">{{ q.position === null ? '—' : Number(q.position).toFixed(1) }}</td>
3114 <td class="py-2 tabular-nums text-right mono">{{ Number(q.impressions) > 0 ? `${(Number(q.clicks) / Number(q.impressions) * 100).toFixed(1)}%` : 'n/a' }}</td>
3115 <td class="pr-1 py-2 tabular-nums text-right mono">{{ q.position === null ? 'n/a' : Number(q.position).toFixed(1) }}</td>
29293116 </tr>
29303117 @endforeach
29313118 </tbody>
@@ -3410,7 +3597,7 @@ select.panel:hover { border-color: var(--accent); }
34103597 const note = document.querySelector('[data-country-missing]')
34113598 if (note) {
34123599 const label = missing.map(c => serverNames[c] || names[c] || c).join(', ')
3413 note.textContent = `${label} ${missing.length === 1 ? 'is' : 'are'} too small to plot at this scale — see the list below.`
3600 note.textContent = `${label} ${missing.length === 1 ? 'is' : 'are'} too small to plot at this scale. See the list below.`
34143601 note.removeAttribute('hidden')
34153602 }
34163603 }
@endif