diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index dc21d958..81b42cf3 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -86,6 +86,7 @@ jobs: - name: Analytics tests (no Google credentials) run: | python -m unittest discover -s tests -v + python tests/render_analytics_dom.py node tests/analytics-js.test.cjs - name: Restore last successful analytics snapshot diff --git a/.makim.yaml b/.makim.yaml index 4a44c0c6..e047a4bb 100644 --- a/.makim.yaml +++ b/.makim.yaml @@ -54,14 +54,28 @@ groups: build/.well-known/funding-manifest-urls preview: - help: preview the web page dynamically + help: preview the website with synthetic analytics by default + backend: bash args: run-pre-build: help: Run pre-build task type: bool action: store_true + no-analytics-fixture: + help: Use saved analytics data or the unavailable state, not fixtures + type: bool + action: store_true hooks: pre-run: - task: pages.pre-build if: ${{ args.run_pre_build }} - run: mkdocs serve --watch pages --watch theme + run: | + set -e + if [[ "${{ args.no_analytics_fixture | lower }}" == "true" ]]; then + unset ANALYTICS_PREVIEW_FIXTURE + echo "Analytics: saved snapshot, or unavailable if none exists." + else + export ANALYTICS_PREVIEW_FIXTURE=tests/fixtures/analytics-explorer.json + echo "Analytics: TEST FIXTURE (synthetic data, local preview only)." + fi + mkdocs serve --watch pages --watch theme diff --git a/README.md b/README.md index ee7bad6c..c4331b0d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,18 @@ poetry install makim pages.preview ``` +Open `http://localhost:8000/analytics/` to explore the dashboard with clearly +labeled synthetic data. Local preview includes this TEST FIXTURE by default; no +Google credentials are needed. To use the saved real analytics snapshot (or show +the unavailable state if none exists): + +```bash +makim pages.preview --no-analytics-fixture +``` + +Fixtures do not overwrite the saved snapshot or affect `makim pages.build` or +production deployments. Either preview mode supports `--run-pre-build`. + ## Linter Ensure you have installed the pre-commit config locally: diff --git a/docs/analytics.md b/docs/analytics.md index b08c9b6d..4d8d1b9a 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -14,23 +14,23 @@ recreate working resources or generate private keys. ## Design and deployment -- `/analytics/` uses the existing MkDocs custom theme and its light/dark tokens. - Metric/comparison cards, daily line and monthly bar charts (inline SVG), - audience rankings and accessible tables are server-rendered from the same - validated report as `/analytics/data.json`. No authenticated report request or - token reaches the browser. Analytics-page JavaScript only switches charts and - updates the three-day stale notice; existing site tracking is separate. - Without JavaScript, both charts and their expandable tables remain usable. +- `/analytics/` is a compact report workspace: Overview, Acquisition, Countries, + Devices and Pages. It uses OSL's shared light/dark tokens, native SVG charts + and accessible HTML tables, with no chart-library or remote dashboard embed. + The 30-day fallback is server-rendered. JavaScript reads the same validated + public report embedded with safe JSON escaping; it makes no network requests. + Without JavaScript, the 30-day figures, monthly history and dimension tables + remain available. Existing site tracking is separate. - `scripts/analytics/export.py` uses Google's Python GA4 Data API client. A small filtered report discovers the property's IANA timezone from response - metadata; no Admin API or manually synchronized timezone variable is needed. - The next query requests `screenPageViews`, `activeUsers`, and `sessions` - without dimensions across the entire 30-day window ending yesterday. The third - query requests `screenPageViews` by `yearMonth` for the prior 12 completed - months. Then it queries the preceding 30 days as a separate dimensionless - summary, daily `screenPageViews` by `date`, and country, device and channel - breakdowns for the current 30-day window: eight reports plus any pagination. - Both headline active-user totals are whole-period queries, never sums. + metadata. It queries **7, 30 and 90 completed days**, each with its own + dimensionless summary, immediately preceding equal-length summary, + current/previous daily metric series, and four current-period breakdowns. + Monthly pageviews cover the prior 12 completed calendar months independently + of the selected window. This is 26 requests before pagination, not 26 browser + calls. Every summary's active-user count is queried independently; daily users + are never summed into a period total. No Admin API or timezone variable is + needed. - Every request applies an exact, case-insensitive `hostName` allowlist AND `platform = web`. Never put previews, localhost or unrelated domains in the allowlist. Hostnames are not inferred from the property, URL, or site tag. @@ -68,23 +68,65 @@ The public metric mapping follows the | `active_users` | `activeUsers` | GA4's distinct active-user count over the entire reporting period, never a sum of daily/monthly counts. | | `sessions` | `sessions` | Sessions that began during the reporting period. | +### Using the dashboard + +1. Choose **Last 7 / 30 / 90 completed days**. Exact dates are shown beside the + selector; all cards and dimension reports update together. Unsupported + presets on older snapshots are disabled, not approximated. +2. Select **Pageviews**, **Active users** or **Sessions** to change the main + daily chart. Hover/touch the plot or use the keyboard-accessible date slider + to inspect exact current/previous values. Daily users count users on that + date only, not a portion of an additive total. +3. Toggle **Previous period** to overlay the preceding equal-length daily series + on a shared scale, aligned by day index. Card comparisons always show the + independently queried period totals. A zero baseline has no percentage + comparison. Use **View data table** for exact daily figures. +4. Open a report from the sidebar or an overview card. Search and sort the + visible table; the ranking preview follows it. **Search filters that table + only**, not the headline totals, other dimensions or the global trend. There + is no unsupported cross-dimension drilldown or arbitrary date picker. +5. **CSV** exports the selected metric's current/previous daily series, or the + currently searched/sorted dimension table. CSV includes dates, timezone, + source/scope, refresh timestamp and fixture/production identity. Shares use + the full panel total even when searching. Cells are quoted and spreadsheet + formula prefixes neutralized. **Download JSON** provides the complete static + snapshot. Neither action contacts Google. + ### Dashboard contract and grouping -New successful exports use **schema version 2**. Existing version 1 snapshots -remain valid and are rendered without inventing the missing comparisons, daily -history or audience panels. Content builds do not upgrade or re-date them. The -public schema validates both versions; consumers should check `schema_version` -and `status` before reading optional panels. Version 2 adds: - -- `comparison`: the preceding 30-day `reporting_period` and `summary`. Percent - changes are display-only; a previous zero shows no percentage baseline. -- `daily_history`: returned `{date, pageviews}` rows within the current window. -- `breakdowns.countries` and `.devices`: `screenPageViews` by `country` and - `deviceCategory`, respectively. Country is approximate activity location, not - nationality or residence; devices describe traffic, not distinct people. -- `breakdowns.channels`: `sessions` by `sessionDefaultChannelGroup`, not - first-user acquisition or event attribution. All panels use the same current - 30-day dates, Web-only filter and exact hostname allowlist. +New successful exports use **schema version 3**. Version 1 and 2 snapshots +remain readable without inventing missing features or changing their original +refresh timestamp. `windows` is null when unavailable, otherwise a closed map +with exactly `"7"`, `"30"` and `"90"`. Each contains: + +- `reporting_period` and `summary` for that independently queried full period. +- `comparison` with the preceding equal-length `reporting_period`, independently + queried `summary` and `daily_history`. +- `daily_history`: only returned rows, containing `date`, `pageviews`, + `active_users` and `sessions`. Missing dates remain absent, not inferred zero. +- `breakdowns`: countries, devices, channels and pages for that current period. + Countries/devices use `screenPageViews` by `country`/`deviceCategory`; + channels use `sessions` by `sessionDefaultChannelGroup`, not first-user + acquisition. + +For compatibility, the existing root 30-day `reporting_period`, `summary`, +`comparison`, pageview-only `daily_history` and three audience `breakdowns` +remain exact projections of `windows["30"]`, validated against it. Root +`monthly_history` still contains up to 12 completed calendar months. Consumers +must check `schema_version` and `status`; v1/v2 reports have no `windows` map. + +**Public-page safety:** `scripts/analytics/routes.py` derives canonical public +routes from MkDocs' file collection and URL rules. The exporter combines those +exact, case-sensitive routes with the Web/hostname filters in a `pagePath` +allowlist on every page of the request. It also rejects returned paths outside +that list. No arbitrary visitor path, query string, full URL, GA-supplied title, +referrer or identifier is published. Only simple canonical route syntax is +accepted; hidden files and non-document assets are excluded. Unknown paths, +noncanonical aliases and routes no longer in the current site are excluded, so +page totals can be lower than headline pageviews. Query parameters on visits to +known pages are not published; GA4's `pagePath` dimension excludes them. The +route catalog has a 500-route safety limit; review the scope rather than +silently truncating if that limit is reached. Each breakdown includes `status`, `metric`, `minimum_active_users`, `total`, `other` and `rows` (`label`, `value`). The exporter uses per-category @@ -93,8 +135,10 @@ naming a category; it does not publish those user counts. At most ten eligible categories are ranked by the additive metric, with deterministic tie ordering. Small, remaining and unclassified categories contribute only to `other`. Grouping reduces published detail but is **not a formal anonymity guarantee**. -No city, path, referrer URL, user identifier, raw response or cross-dimensional -report is published. +No city, arbitrary path, referrer URL, user identifier, raw response or +cross-dimensional report is published. Named page routes use the same +whole-period active-user threshold. Their grouped row is labeled **Other public +pages**; unknown visitor paths are excluded entirely. Shares are display-only and use each panel's own returned additive `total`, including `other`, not distinct users or the headline denominator. Rounded @@ -109,9 +153,9 @@ row counts/timezones, missing pages and explicit data loss rather than publishing a truncated top-ten denominator. All recent windows and panels are queried again on every refresh. Only a valid complete export is written atomically; a network or permission failure in a new query retains even an old -version 1 snapshot byte-for-byte locally, with its original refresh timestamp. -Explicit GA4-restricted breakdowns are the documented exception: their -`status: withheld`, null `total`/`other`, and empty `rows` honestly signal +version 1 or 2 snapshot byte-for-byte locally, with its original refresh +timestamp. Explicit GA4-restricted breakdowns are the documented exception: +their `status: withheld`, null `total`/`other`, and empty `rows` honestly signal non-publication. A successful unrestricted empty panel is instead `available` with zero totals. @@ -119,8 +163,8 @@ with zero totals. **new** manual run on `main`. No new Google APIs, roles, secrets or workflow identifiers are needed. The push renders the retained report; only a successful scheduled/manual export fills the new panels. Re-running only a deploy job does -not fetch new statistics. A rollback must keep a schema-v2-capable reader once -the durable snapshot is v2; do not delete the report to bypass validation. +not fetch new statistics. A rollback must keep a schema-v3-capable reader once +the durable snapshot is v3; do not delete the report to bypass validation. ### Durable snapshot storage @@ -474,7 +518,7 @@ In **OpenScienceLabs/opensciencelabs.github.io**, not a fork: `https://opensciencelabs.org/analytics/data.json`. Check `status: available`, `data_kind: production`, exact hostnames and property timezone, the dates ending yesterday **in that timezone at export time**, and `generated_at`. The - new report should have `schema_version: 2`; cards, both charts and all + new report should have `schema_version: 3`; cards, both charts and all audience tables must match the JSON. Withheld panels must have null totals, not zero. A successful export with all zeros should prompt verification of collection/property/hostname configuration; it does not prove zero actual @@ -528,6 +572,7 @@ In the existing `osl-web` environment: poetry install python -m pip install -r requirements-analytics.txt python -m unittest discover -s tests -v +python tests/render_analytics_dom.py node tests/analytics-js.test.cjs poetry check ruff check scripts/analytics tests @@ -536,26 +581,61 @@ makim pages.build python -m scripts.analytics.audit ``` +The DOM preparation command writes labeled fixture markup only to ignored +`.cache/analytics-dom-fixtures.json`; it is never a publication artifact. +`tests/fixtures/analytics.json` retains the version 2 migration fixture and +`analytics-v1.json` the version 1 fixture. The explorer fixture is synthetic +throughout, including its daily users and public-page counts. + The Google SDK compatibility test is skipped only if the SDK is unavailable locally; CI installs the pinned extras and runs it. Offline tests use synthetic SDK-shaped responses, not recordings or credentials. The website already receives `jsonschema` through its locked notebook dependencies; the extras file also declares that dependency explicitly and pins it to the existing lock. -For an **explicit fixture preview**, use a separate output directory: +### Local dashboard preview + +The existing Makim preview task uses the synthetic explorer fixture by default, +so the full dashboard works locally without Google credentials or a downloaded +snapshot: + +```bash +makim pages.preview +``` + +Visit `http://localhost:8000/analytics/`. The prominent TEST FIXTURE banner and +`data_kind: fixture` in `/analytics/data.json` identify the synthetic report. To +preview the saved real snapshot at `.cache/analytics/data.json` instead: + +```bash +makim pages.preview --no-analytics-fixture +``` + +Without a saved snapshot, this opt-out shows **Analytics data is not available +yet**, not invented statistics. Both commands support `--run-pre-build` when +blog sources need rendering first. + +The fixture environment variable is set only in the preview task's process. The +opt-out explicitly clears it, including an inherited value. Neither mode +overwrites the saved snapshot or fetches data from Google. `makim pages.build` +and production deployments do **not** enable fixtures by default. + +For a **static fixture preview**, use a separate output directory: ```bash -ANALYTICS_PREVIEW_FIXTURE=tests/fixtures/analytics.json \ +ANALYTICS_PREVIEW_FIXTURE=tests/fixtures/analytics-explorer.json \ mkdocs build --site-dir .cache/analytics-preview python -m http.server 8000 --directory .cache/analytics-preview ``` -Visit `http://localhost:8000/analytics/`. The prominent TEST FIXTURE banner and -`data_kind: fixture` must be present. Never deploy this preview directory. CI -rejects the preview environment variable, and the publication audit rejects -fixture JSON even if copied to `build/`. Without the variable, normal builds use -only the ignored restored snapshot or the unavailable state. The live exporter -refuses to authenticate outside trusted CI. +The TEST FIXTURE banner and `data_kind: fixture` must be present in this mode +too. Never deploy this preview directory. CI rejects the preview environment +variable, and the publication audit rejects fixture JSON even if copied to +`build/`. Without the variable, normal builds use only the ignored restored +snapshot or the unavailable state. The live exporter refuses to authenticate +outside trusted CI. + +### Browser verification For reproducible real-browser screenshots, with the preview server running in another terminal: @@ -570,12 +650,13 @@ python tests/browser_analytics.py \ This optional smoke check uses [Playwright](https://playwright.dev/python/docs/emulation), checks both modes at 1440px, 390px and 320px, rejects horizontal page overflow, checks download -focus, keyboard chart switching, table disclosures and a JavaScript-disabled -page, and writes screenshots under `.cache/analytics-screenshots/fixture/`. It -blocks Google tracking requests in its color-mode checks, and accepts only -localhost URLs. Stop the fixture server, serve the normal `build/` with -`python -m http.server 8000 --directory build`, then run the smoke check again -with `--output .cache/analytics-screenshots/normal`. When no restored snapshot +focus, date/metric switching, report navigation, table search/sort, CSV +downloads and JavaScript-disabled pages, and writes screenshots under +`.cache/analytics-screenshots/fixture/`. It blocks Google tracking requests in +its color-mode checks, and accepts only localhost URLs. Stop the fixture server, +serve the normal `build/` with `python -m http.server 8000 --directory build`, +then run the smoke check again with +`--output .cache/analytics-screenshots/normal`. When no restored snapshot exists, this covers the honest unavailable state. Inspect the PNGs rather than assuming automated checks prove visual quality. Ordinary browser previews may execute the base theme's tracking tag; block analytics requests locally rather @@ -583,8 +664,9 @@ than sending synthetic preview visits to Google. Before release, inspect desktop (1440px) and mobile (390px and 320px) in both shared color modes, keyboard focus, table scrolling, browser zoom, the zero-data -chart, stale notice, and unavailable state. All figures and the table must also -work with JavaScript disabled. The base viewport now permits user zoom. +chart, stale notice, and unavailable state. The default 30-day report and its +tables must also work with JavaScript disabled; other presets remain +downloadable in the JSON. The base viewport now permits user zoom. ### Release evidence checklist @@ -608,12 +690,12 @@ raw API responses, or private account information in those records. scheduled or manual refresh updates it. The retained/unavailable state and failed-run signal are checked for refresh failures. -Mocked tests and a populated workflow are **not live GA4 verification**. Static -markup tests are **not browser visual verification**. If SDK downloads, a -browser or localhost sockets are unavailable in the testing environment, record -those checks as skipped and run them in a capable environment before release. Do -not replace missing evidence with fixture statistics or an assumed successful -run. +Mocked tests and a populated workflow are **not live GA4 verification**. The +Node DOM harness executes real explorer code against rendered fixture markup, +but is **not browser visual verification**. If SDK downloads, a browser or +localhost sockets are unavailable in the testing environment, record those +checks as skipped and run them in a capable environment before release. Do not +replace missing evidence with fixture statistics or an assumed successful run. ## Troubleshooting @@ -646,8 +728,9 @@ them to match a screenshot with unknown dates. Download the public JSON using the command above and compare the following in the authenticated GA4 UI: 1. **Freshness and version:** read the actual `generated_at`, not the deployment - time. A retained report can be valid but stale. A v1 snapshot lacks the new - panels until a successful new export; it has not lost that data. + time. A retained report can be valid but stale. A v1/v2 snapshot lacks the + new presets and page report until a successful new export; it has not lost + that data. 2. **Property and collection:** verify property `365530978`, its Web stream and the live Google tag. The checked-in legacy `UA-213158050-1` tag alone does not establish GA4 collection. Check for an existing GA4/Tag Manager diff --git a/pages/analytics/index.md b/pages/analytics/index.md index 5aee7431..006102a3 100644 --- a/pages/analytics/index.md +++ b/pages/analytics/index.md @@ -20,19 +20,25 @@ published; no visitor identifiers or raw API responses are included. repeated views of the same page. The Web platform filter excludes app screens. - **Active users** (`activeUsers`): distinct users GA4 classifies as active, based on engagement and applicable first-visit or engagement signals. This is - queried over each entire 30-day period, not added from daily totals. It is not - a count of all visitors or a census of individual people. + queried over each entire selected period, not added from daily totals. It is + not a count of all visitors or a census of individual people. - **Sessions** (`sessions`): sessions that began during the reporting period, as measured by GA4. A user can have more than one session. All dates are inclusive calendar dates in the **GA4 property's reporting -timezone**, shown above. The summary covers 30 completed days ending yesterday -at the time of the last successful refresh. Comparisons use the immediately -preceding 30 completed days in the same timezone, not the previous calendar -month. Percentage changes have no meaningful baseline when the previous count is -zero. The daily chart covers the summary period; monthly history covers up to 12 -completed calendar months, excluding the current month. These are different -reporting windows, not directly comparable totals. +timezone**, shown above. The default summary covers 30 completed days ending +yesterday at the last successful refresh. The date selector also offers **7 and +90 completed days** when the snapshot supports them. Each period's summary is +queried independently. Comparisons use the immediately preceding period of the +same length, not the previous calendar month. A previous zero has no meaningful +percentage baseline. + +The daily chart switches between pageviews, active users and sessions. Daily +active-user points are distinct counts for individual dates: **do not sum them +into the period total**. The comparison line aligns the previous period by day +index, using the same scale; date inspection and the table show both exact +dates. Monthly pageviews cover up to 12 completed calendar months independently +of the selected window. The current month is excluded. Missing dates and months are marked **Not reported**, with gaps in the chart; they are not invented as zeros and may predate collection. Explicit zero counts @@ -53,14 +59,21 @@ community platforms, or other websites. - **Channels** show sessions by `sessionDefaultChannelGroup`, GA4's rule-based classification of how sessions started. This is session acquisition, not a user's first-ever acquisition channel or a campaign attribution audit. - -Each panel uses the same 30-day window and hostname scope as the headline. Up to -ten named categories are ranked by their additive metric. A category needs at +- **Pages** show pageviews by `pagePath`, restricted to canonical public routes + derived from this MkDocs website. Unknown paths, noncanonical aliases and + routes no longer in the current site are excluded. Query strings, full URLs, + GA-supplied page titles and visitor identifiers are never published. Page + totals can therefore be lower than headline pageviews. + +Each panel uses the same selected window and hostname scope as the headline. Up +to ten named categories are ranked by their additive metric. A category needs at least ten `activeUsers` over the full period to be named; smaller, remaining and unclassified categories are combined as **Other / unknown**. Those per-category user counts are used only for grouping and are never published or summed into headline users. Grouping reduces detail; it is not a formal anonymity guarantee. -No city, visitor, page-path or cross-category breakdowns are published. +No city, visitor or cross-category breakdowns are published. Page statistics are +restricted to known public routes as described above. Smaller/remaining known +page routes are labeled **Other public pages**, not unknown visitor paths. Shares use each panel's returned total, including Other / unknown, not the headline user count. Rounded shares may not add to exactly 100%. Dimensioned and @@ -68,6 +81,14 @@ headline reports may differ during processing or because of reporting semantics; we do not adjust them to force agreement. A retained older snapshot may lack comparisons or audience panels; that absence is not zero traffic. +Table search and sorting affect only that table, not the headline totals or +other reports. CSV downloads contain the currently selected daily metric or +filtered dimension table, with reporting dates and provenance. The JSON contains +all published windows. This is a daily-updated public reporting tool, not +realtime GA4 access or an arbitrary-date/cross-dimension query interface. Older +snapshots keep unavailable controls disabled; no statistics are invented for +missing periods. + ### Limitations and freshness Measured traffic can exclude visits affected by **consent choices, blockers**, @@ -92,8 +113,8 @@ Metric definitions follow Google's See also Google's [data freshness guidance](https://support.google.com/analytics/answer/12233314). The endpoint follows our [versioned JSON schema](schema.json). New exports use -version 2; older version 1 snapshots remain readable without changing their -original refresh timestamp. +version 3; older version 1 and 2 snapshots remain readable without changing +their original refresh timestamp. ## Support open science diff --git a/pages/analytics/schema.json b/pages/analytics/schema.json index 05d971d2..20de1fc1 100644 --- a/pages/analytics/schema.json +++ b/pages/analytics/schema.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://opensciencelabs.org/analytics/schema.json", - "title": "OSL public analytics report v1/v2", - "description": "Closed aggregate-only contract. v2 adds preceding-period totals, daily pageviews and privacy-filtered audience breakdowns. Retained v1 snapshots remain valid without invented new fields. All dates are property-local; generated_at is the last successful export in UTC.", + "title": "OSL public analytics report v1/v2/v3", + "description": "Closed aggregate-only contract. v3 adds independently queried 7/30/90-day windows, daily metric comparisons, and allowlisted public-page rankings. Retained v1/v2 snapshots are not rewritten.", "type": "object", "additionalProperties": false, "required": [ @@ -21,7 +21,7 @@ ], "properties": { "schema_version": { - "enum": [1, 2] + "enum": [1, 2, 3] }, "data_kind": { "enum": ["production", "fixture"] @@ -205,6 +205,29 @@ } } ] + }, + "windows": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": ["7", "30", "90"], + "properties": { + "7": { + "$ref": "#/$defs/window" + }, + "30": { + "$ref": "#/$defs/window" + }, + "90": { + "$ref": "#/$defs/window" + } + } + } + ] } }, "allOf": [ @@ -324,6 +347,48 @@ } ] } + }, + { + "if": { + "properties": { + "schema_version": { + "const": 3 + } + } + }, + "then": { + "required": ["windows"], + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "available" + } + } + }, + "then": { + "properties": { + "windows": { + "type": "object" + } + } + }, + "else": { + "properties": { + "windows": { + "type": "null" + } + } + } + } + ] + }, + "else": { + "not": { + "required": ["windows"] + } + } } ], "$defs": { @@ -459,6 +524,216 @@ } } ] + }, + "daily_metrics": { + "type": "array", + "maxItems": 90, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["date", "pageviews", "active_users", "sessions"], + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "pageviews": { + "$ref": "#/$defs/count" + }, + "active_users": { + "$ref": "#/$defs/count" + }, + "sessions": { + "$ref": "#/$defs/count" + } + } + } + }, + "pages_breakdown": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "metric", + "minimum_active_users", + "total", + "other", + "rows" + ], + "properties": { + "status": { + "enum": ["available", "withheld"] + }, + "metric": { + "const": "pageviews" + }, + "minimum_active_users": { + "const": 10 + }, + "total": { + "oneOf": [ + { + "$ref": "#/$defs/count" + }, + { + "type": "null" + } + ] + }, + "other": { + "oneOf": [ + { + "$ref": "#/$defs/count" + }, + { + "type": "null" + } + ] + }, + "rows": { + "type": "array", + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["label", "value"], + "properties": { + "label": { + "type": "string", + "maxLength": 240, + "pattern": "^/(?:[A-Za-z0-9_-]+/)*(?:[A-Za-z0-9_-]+\\.html)?$" + }, + "value": { + "$ref": "#/$defs/count" + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "withheld" + } + } + }, + "then": { + "properties": { + "total": { + "type": "null" + }, + "other": { + "type": "null" + }, + "rows": { + "maxItems": 0 + } + } + }, + "else": { + "properties": { + "total": { + "$ref": "#/$defs/count" + }, + "other": { + "$ref": "#/$defs/count" + } + } + } + } + ] + }, + "window": { + "type": "object", + "additionalProperties": false, + "required": [ + "reporting_period", + "summary", + "comparison", + "daily_history", + "breakdowns" + ], + "properties": { + "reporting_period": { + "$ref": "#/$defs/period" + }, + "summary": { + "$ref": "#/$defs/summary" + }, + "comparison": { + "type": "object", + "additionalProperties": false, + "required": ["reporting_period", "summary", "daily_history"], + "properties": { + "reporting_period": { + "$ref": "#/$defs/period" + }, + "summary": { + "$ref": "#/$defs/summary" + }, + "daily_history": { + "$ref": "#/$defs/daily_metrics" + } + } + }, + "daily_history": { + "$ref": "#/$defs/daily_metrics" + }, + "breakdowns": { + "type": "object", + "additionalProperties": false, + "required": ["countries", "devices", "channels", "pages"], + "properties": { + "countries": { + "allOf": [ + { + "$ref": "#/$defs/breakdown" + }, + { + "properties": { + "metric": { + "const": "pageviews" + } + } + } + ] + }, + "devices": { + "allOf": [ + { + "$ref": "#/$defs/breakdown" + }, + { + "properties": { + "metric": { + "const": "pageviews" + } + } + } + ] + }, + "channels": { + "allOf": [ + { + "$ref": "#/$defs/breakdown" + }, + { + "properties": { + "metric": { + "const": "sessions" + } + } + } + ] + }, + "pages": { + "$ref": "#/$defs/pages_breakdown" + } + } + } + } } } } diff --git a/scripts/analytics/export.py b/scripts/analytics/export.py index 355e5c22..e16f8e9f 100644 --- a/scripts/analytics/export.py +++ b/scripts/analytics/export.py @@ -15,14 +15,17 @@ METRICS, MIN_GROUP_USERS, SNAPSHOT, + WINDOW_PRESETS, hostnames, month_period, periods, previous_period, unavailable, validate, + window_period, write_snapshot, ) +from scripts.analytics.routes import public_routes SCOPE = "https://www.googleapis.com/auth/analytics.readonly" REPOSITORY = "OpenScienceLabs/opensciencelabs.github.io" @@ -83,9 +86,9 @@ def from_env(cls, env): return cls(property_id, hostnames(env.get("GA4_HOSTNAMES", ""))) -def dimension_filter(hosts: list[str]) -> dict: +def dimension_filter(hosts: list[str], paths=None) -> dict: """AND exact allowed hostnames with Web to exclude apps and other sites.""" - return { + result = { "and_group": { "expressions": [ { @@ -110,9 +113,26 @@ def dimension_filter(hosts: list[str]) -> dict: ] } } + if paths is not None: + if not paths: + raise ValueError("Empty public-page scope") + result["and_group"]["expressions"].append( + { + "filter": { + "field_name": "pagePath", + "in_list_filter": { + "values": paths, + "case_sensitive": True, + }, + }, + } + ) + return result -def query_page(client, settings, period, metrics, dimensions, offset): +def query_page( + client, settings, period, metrics, dimensions, offset, paths=None +): """Validate one bounded page; retain only counts and requested labels.""" request = dict( property=f"properties/{settings.property_id}", @@ -121,7 +141,7 @@ def query_page(client, settings, period, metrics, dimensions, offset): ], metrics=[{"name": name} for name in metrics], dimensions=[{"name": name} for name in dimensions], - dimension_filter=dimension_filter(settings.hosts), + dimension_filter=dimension_filter(settings.hosts, paths), keep_empty_rows=True, limit=PAGE_SIZE, offset=offset, @@ -175,14 +195,14 @@ def query_page(client, settings, period, metrics, dimensions, offset): return metadata.time_zone, response.row_count, result -def query(client, settings, period, metrics, dimensions=()): +def query(client, settings, period, metrics, dimensions=(), *, paths=None): """Paginate with stable ordering; reject changing or duplicated rows.""" zone, total, result = query_page( - client, settings, period, metrics, dimensions, 0 + client, settings, period, metrics, dimensions, 0, paths ) while len(result) < total: next_zone, next_total, rows = query_page( - client, settings, period, metrics, dimensions, len(result) + client, settings, period, metrics, dimensions, len(result), paths ) if (next_zone, next_total) != (zone, total): raise ValueError("GA4 report changed during pagination") @@ -201,7 +221,16 @@ def summary_counts(rows): return {public: counts[api] for api, public in METRICS.items()} -def breakdown(client, settings, period, dimension, api_metric, public_metric): +def breakdown( + client, + settings, + period, + dimension, + api_metric, + public_metric, + *, + paths=None, +): """Publish coarse rankings, grouping small and unclassified categories.""" panel = { "status": "withheld", @@ -213,7 +242,12 @@ def breakdown(client, settings, period, dimension, api_metric, public_metric): } try: zone, rows = query( - client, settings, period, [api_metric, "activeUsers"], [dimension] + client, + settings, + period, + [api_metric, "activeUsers"], + [dimension], + paths=paths, ) except RestrictedReport as error: # An explicit GA4 restriction is an honest unavailable panel. Network, @@ -224,6 +258,8 @@ def breakdown(client, settings, period, dimension, api_metric, public_metric): visible = [] for dims, counts in rows: label = dims[0] + if paths is not None and label not in paths: + raise ValueError("Unexpected page outside the public route scope") if counts["activeUsers"] < MIN_GROUP_USERS or label.lower() in { "", "(not set)", @@ -231,7 +267,7 @@ def breakdown(client, settings, period, dimension, api_metric, public_metric): "unknown", }: continue - if not re.fullmatch(r"[^\x00-\x1f<>]{1,80}", label): + if paths is None and not re.fullmatch(r"[^\x00-\x1f<>]{1,80}", label): raise ValueError("Invalid aggregate label") visible.append({"label": label, "value": counts[api_metric]}) visible.sort(key=lambda row: (-row["value"], row["label"])) @@ -244,10 +280,72 @@ def breakdown(client, settings, period, dimension, api_metric, public_metric): return zone, panel +def daily_counts(rows): + """Map returned daily metrics without filling absent dates with zeros.""" + result = [] + for dims, counts in rows: + day = dims[0] + if not re.fullmatch(r"[0-9]{8}", day): + raise ValueError("Invalid GA4 date") + result.append( + { + "date": f"{day[:4]}-{day[4:6]}-{day[6:]}", + **{public: counts[api] for api, public in METRICS.items()}, + } + ) + return sorted(result, key=lambda row: row["date"]) + + +def collect_window(client, settings, period, zone, paths, summary=None): + """Query each preset and comparison independently, including users.""" + + def checked_query(dates, dimensions=()): + returned_zone, rows = query( + client, settings, dates, list(METRICS), dimensions + ) + if returned_zone != zone: + raise ValueError("Property timezone changed during export") + return rows + + if summary is None: + summary = summary_counts(checked_query(period)) + previous = previous_period(period) + comparison = summary_counts(checked_query(previous)) + daily = daily_counts(checked_query(period, ["date"])) + panels = {} + for key, spec in BREAKDOWNS.items(): + panel_zone, panels[key] = breakdown(client, settings, period, *spec) + if panel_zone != zone: + raise ValueError("Property timezone changed during export") + panel_zone, panels["pages"] = breakdown( + client, + settings, + period, + "pagePath", + "screenPageViews", + "pageviews", + paths=paths, + ) + if panel_zone != zone: + raise ValueError("Property timezone changed during export") + return { + "reporting_period": period, + "summary": summary, + "comparison": { + "reporting_period": previous, + "summary": comparison, + "daily_history": daily_counts(checked_query(previous, ["date"])), + }, + "daily_history": daily, + "breakdowns": panels, + } + + def collect(client, settings: Settings, now: datetime | None = None) -> dict: """Re-query all periods; activeUsers is one period-level distinct total.""" # Relative dates are interpreted by GA4 in its property timezone. This # small probe discovers that timezone without requiring the Admin API. + paths = public_routes() zone, _ = query( client, settings, @@ -290,36 +388,30 @@ def collect(client, settings: Settings, now: datetime | None = None) -> dict: # Missing months remain absent: GA4 cannot prove whether collection was # enabled then. Explicit returned zeros, however, are preserved. report["monthly_history"].sort(key=lambda item: item["month"]) - comparison = previous_period(rolling) - comparison_zone, comparison_rows = query( - client, settings, comparison, list(METRICS) + window = collect_window( + client, settings, rolling, zone, paths, summary=report["summary"] ) + report["windows"] = {"30": window} + for days in WINDOW_PRESETS: + if str(days) == "30": + continue + report["windows"][str(days)] = collect_window( + client, settings, window_period(started, zone, days), zone, paths + ) + # Exact 30-day compatibility projection; never a second calculation. report["comparison"] = { - "reporting_period": comparison, - "summary": summary_counts(comparison_rows), + key: window["comparison"][key] + for key in ("reporting_period", "summary") + } + report["daily_history"] = [ + {key: row[key] for key in ("date", "pageviews")} + for row in window["daily_history"] + ] + report["breakdowns"] = { + key: panel + for key, panel in window["breakdowns"].items() + if key != "pages" } - daily_zone, daily_rows = query( - client, settings, rolling, ["screenPageViews"], ["date"] - ) - if (comparison_zone, daily_zone) != (zone, zone): - raise ValueError("Property timezone changed during export; retry") - for dims, counts in daily_rows: - day = dims[0] - if not re.fullmatch(r"[0-9]{8}", day): - raise ValueError("Invalid GA4 date") - report["daily_history"].append( - { - "date": f"{day[:4]}-{day[4:6]}-{day[6:]}", - "pageviews": counts["screenPageViews"], - } - ) - report["daily_history"].sort(key=lambda row: row["date"]) - report["breakdowns"] = {} - for key, spec in BREAKDOWNS.items(): - panel_zone, panel = breakdown(client, settings, rolling, *spec) - if panel_zone != zone: - raise ValueError("Property timezone changed during export; retry") - report["breakdowns"][key] = panel finished = now or datetime.now(timezone.utc) if periods(finished, zone) != (rolling, history): raise ValueError("Property midnight crossed during export; retry") diff --git a/scripts/analytics/presentation.py b/scripts/analytics/presentation.py index bf63a3b1..cc8a2104 100644 --- a/scripts/analytics/presentation.py +++ b/scripts/analytics/presentation.py @@ -98,7 +98,7 @@ def dashboard(report): ( "active_users", "Active users", - "Distinct active users over all 30 days", + "Distinct active users over the full reporting period", ), ( "sessions", @@ -149,14 +149,25 @@ def dashboard(report): ("countries", "Where our audience is", "Country-level pageviews"), ("devices", "How people visit", "Pageviews by device category"), ("channels", "How people find us", "Sessions by acquisition channel"), + ("pages", "Top pages", "Pageviews on known public OSL pages"), ]: - panel = (report.get("breakdowns") or {}).get(key) + panels = ( + report["windows"]["30"]["breakdowns"] + if report.get("windows") + else report.get("breakdowns") or {} + ) + panel = panels.get(key) item = {"key": key, "title": title, "description": description} item.update(panel or {"status": "missing"}) item["display_rows"] = [] if panel and panel["status"] == "available": rows = panel["rows"] + [ - {"label": "Other / unknown", "value": panel["other"]} + { + "label": "Other public pages" + if key == "pages" + else "Other / unknown", + "value": panel["other"], + } ] for row in rows: share = ( diff --git a/scripts/analytics/report.py b/scripts/analytics/report.py index 7a44e7f7..553b29b4 100644 --- a/scripts/analytics/report.py +++ b/scripts/analytics/report.py @@ -30,6 +30,18 @@ MAX_HOSTNAME_LENGTH = 253 MIN_GROUP_USERS = 10 MAX_GROUP_ROWS = 10 +WINDOW_PRESETS = (7, 30, 90) + + +def window_period(now: datetime, zone: str, days: int) -> dict: + """Return a supported completed-day window in the property timezone.""" + if now.tzinfo is None or days not in WINDOW_PRESETS: + raise ValueError("An aware time and supported window are required") + today = now.astimezone(ZoneInfo(zone)).date() + return { + "start": (today - timedelta(days=days)).isoformat(), + "end": (today - timedelta(days=1)).isoformat(), + } def hostnames(value: str) -> list[str]: @@ -75,10 +87,11 @@ def month_period(month: str) -> dict: def previous_period(period: dict) -> dict: - """Use the preceding 30 calendar days, without elapsed-time arithmetic.""" + """Use an adjacent equally long period, without elapsed-time arithmetic.""" start = date.fromisoformat(period["start"]) + days = (date.fromisoformat(period["end"]) - start).days + 1 return { - "start": (start - timedelta(days=WINDOW_DAYS)).isoformat(), + "start": (start - timedelta(days=days)).isoformat(), "end": (start - timedelta(days=1)).isoformat(), } @@ -86,7 +99,7 @@ def previous_period(period: dict) -> dict: def unavailable() -> dict: """Represent an unconfigured first deployment without invented values.""" return { - "schema_version": 2, + "schema_version": 3, "data_kind": "production", "status": "unavailable", "source": dict(SOURCE), @@ -101,6 +114,7 @@ def unavailable() -> dict: "comparison": None, "daily_history": [], "breakdowns": None, + "windows": None, } @@ -153,7 +167,14 @@ def validate(report: dict, *, allow_fixture: bool = False) -> None: for day in dates ): raise ValueError("Daily history must be unique and within the period") - for panel in report["breakdowns"].values(): + validate_breakdowns(report["breakdowns"]) + if "windows" in report: + validate_windows(report, generated) + + +def validate_breakdowns(breakdowns): + """Reject duplicate labels, incorrect ordering and incomplete totals.""" + for panel in breakdowns.values(): if panel["status"] != "available": continue labels = [row["label"] for row in panel["rows"]] @@ -170,6 +191,50 @@ def validate(report: dict, *, allow_fixture: bool = False) -> None: raise ValueError("Breakdown counts do not reconcile") +def validate_daily(rows, period): + """Daily distinct users describe each date, never the period total.""" + dates = [row["date"] for row in rows] + if dates != sorted(set(dates)) or any( + not period["start"] <= day <= period["end"] for day in dates + ): + raise ValueError("Daily rows must be unique and inside their window") + + +def validate_windows(report, generated): + """Validate all presets and their exact compatibility projection.""" + for days in WINDOW_PRESETS: + window = report["windows"][str(days)] + period = window_period(generated, report["timezone"], days) + if window["reporting_period"] != period: + raise ValueError("Incorrect window dates") + previous = window["comparison"] + if previous["reporting_period"] != previous_period(period): + raise ValueError("Incorrect previous-window dates") + validate_daily(window["daily_history"], period) + validate_daily(previous["daily_history"], previous["reporting_period"]) + validate_breakdowns(window["breakdowns"]) + window = report["windows"][str(WINDOW_DAYS)] + projection = { + "reporting_period": window["reporting_period"], + "summary": window["summary"], + "comparison": { + key: window["comparison"][key] + for key in ("reporting_period", "summary") + }, + "daily_history": [ + {key: row[key] for key in ("date", "pageviews")} + for row in window["daily_history"] + ], + "breakdowns": { + key: panel + for key, panel in window["breakdowns"].items() + if key != "pages" + }, + } + if any(report[key] != value for key, value in projection.items()): + raise ValueError("30-day compatibility fields differ from the window") + + def read_snapshot(path: Path, *, allow_fixture: bool = False) -> dict: """Treat absence as unavailable; never silently discard corrupt data.""" if not path.exists(): diff --git a/scripts/analytics/routes.py b/scripts/analytics/routes.py new file mode 100644 index 00000000..10927cdc --- /dev/null +++ b/scripts/analytics/routes.py @@ -0,0 +1,32 @@ +"""Derive the analytics page-path allowlist from public MkDocs documents.""" + +import re + +from mkdocs.config import load_config +from mkdocs.structure.files import get_files + +from scripts.analytics.report import ROOT + +PUBLIC_PATH = re.compile(r"/(?:[A-Za-z0-9_-]+/)*(?:[A-Za-z0-9_-]+\.html)?") +MAX_PUBLIC_ROUTES = 500 +MAX_ROUTE_LENGTH = 240 + + +def public_routes(config_path=ROOT / "mkdocs.yml"): + """Use MkDocs URL rules, excluding hidden/non-document and excluded files. + + Only canonical routes are accepted. No query strings, aliases, visitor + paths, URLs or page titles from Google are added to the allowlist. + """ + config = load_config(str(config_path)) + routes = set() + for file in get_files(config).documentation_pages(): + inclusion = getattr(file, "inclusion", None) + if inclusion is not None and inclusion.is_excluded(): + continue + route = "/" if file.url == "./" else "/" + file.url + if len(route) <= MAX_ROUTE_LENGTH and PUBLIC_PATH.fullmatch(route): + routes.add(route) + if not routes or len(routes) > MAX_PUBLIC_ROUTES: + raise ValueError("Missing or excessive public route allowlist") + return sorted(routes) diff --git a/tests/analytics-dom.cjs b/tests/analytics-dom.cjs new file mode 100644 index 00000000..2a975cb4 --- /dev/null +++ b/tests/analytics-dom.cjs @@ -0,0 +1,222 @@ +/* Minimal DOM harness for behavior tests, NOT browser/layout verification. */ +const vm = require("node:vm"); +const fs = require("node:fs"); +const model = require("../theme/js/analytics-model.js"); +function setup(fixture = "tests/fixtures/analytics-explorer.json") { + let document; + class Node { + constructor(tag, attrs = {}, nodes = []) { + this.tagName = tag; + this.attrs = attrs; + this.nodes = []; + this.listeners = {}; + this.style = {}; + this.dataset = new Proxy( + {}, + { + get: (_, key) => + this.attrs[ + "data-" + key.replace(/[A-Z]/g, (x) => "-" + x.toLowerCase()) + ], + set: (_, key, value) => { + this.attrs[ + "data-" + key.replace(/[A-Z]/g, (x) => "-" + x.toLowerCase()) + ] = String(value); + return true; + }, + }, + ); + this.classList = { + add: (name) => { + this.attrs.class = [this.attrs.class || "", name].join(" "); + }, + }; + this.append(...nodes); + } + append(...nodes) { + nodes.forEach((node) => { + if (node instanceof Node) node.parentElement = this; + this.nodes.push(node); + }); + } + replaceChildren(...nodes) { + this.nodes = []; + this.append(...nodes); + } + remove() { + this.parentElement.nodes = this.parentElement.nodes.filter( + (node) => node !== this, + ); + } + get textContent() { + return this.nodes + .map((node) => (node instanceof Node ? node.textContent : node)) + .join(""); + } + set textContent(value) { + this.nodes = [String(value)]; + } + get className() { + return this.attrs.class || ""; + } + set className(value) { + this.attrs.class = value; + } + get dateTime() { + return this.attrs.datetime; + } + get value() { + return this._value ?? this.attrs.value ?? ""; + } + set value(value) { + this._value = String(value); + } + get hidden() { + return Object.hasOwn(this.attrs, "hidden"); + } + set hidden(value) { + if (value) this.attrs.hidden = ""; + else delete this.attrs.hidden; + } + get disabled() { + return Object.hasOwn(this.attrs, "disabled"); + } + set disabled(value) { + if (value) this.attrs.disabled = ""; + else delete this.attrs.disabled; + } + get checked() { + return Object.hasOwn(this.attrs, "checked"); + } + set checked(value) { + if (value) this.attrs.checked = ""; + else delete this.attrs.checked; + } + get options() { + return this.querySelectorAll("option"); + } + setAttribute(key, value) { + this.attrs[key] = String(value); + } + getAttribute(key) { + return this.attrs[key] ?? null; + } + removeAttribute(key) { + delete this.attrs[key]; + } + matches(selector) { + const tag = selector.match(/^[\w-]+/); + if (tag && tag[0] !== this.tagName) return false; + const id = selector.match(/#([\w-]+)/); + if (id && this.attrs.id !== id[1]) return false; + const cls = selector.match(/\.([\w-]+)/); + if (cls && !this.className.split(" ").includes(cls[1])) return false; + for (const match of selector.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) + if ( + !Object.hasOwn(this.attrs, match[1]) || + (match[2] != null && this.attrs[match[1]] !== match[2]) + ) + return false; + return true; + } + querySelectorAll(selector) { + const tokens = selector.split(/\s+/); + let scopes = [this]; + for (const token of tokens) { + const results = []; + function walk(node) { + for (const child of node.nodes) + if (child instanceof Node) { + if (child.matches(token)) results.push(child); + walk(child); + } + } + scopes.forEach(walk); + scopes = [...new Set(results)]; + } + return scopes; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + closest(selector) { + return this.matches(selector) + ? this + : this.parentElement?.closest(selector); + } + addEventListener(type, fn) { + (this.listeners[type] ||= []).push(fn); + } + dispatch(type, extras = {}) { + const event = { target: this, preventDefault() {}, ...extras }; + let node = this; + while (node) { + (node.listeners[type] || []).forEach((fn) => fn(event)); + node = node.parentElement; + } + this["on" + type]?.(event); + } + click() { + if (!this.disabled) this.dispatch("click"); + } + focus() { + document.activeElement = this; + } + getBoundingClientRect() { + return { left: 0, width: 900 }; + } + } + const raw = JSON.parse( + fs.readFileSync(".cache/analytics-dom-fixtures.json", "utf8"), + )[fixture]; + if (!raw) throw new Error("Run python tests/render_analytics_dom.py first"); + const make = (raw) => + typeof raw === "string" + ? raw + : new Node(raw.tag, raw.attrs, raw.nodes.map(make)); + document = make(raw); + document.createElement = (tag) => new Node(tag); + document.createElementNS = (_, tag) => new Node(tag); + document.getElementById = (id) => document.querySelector("#" + id); + const blobs = [], + timers = [], + events = {}; + const context = { + OSLAnalytics: model, + document, + Date, + Intl, + Object, + JSON, + String, + Number, + Math, + Blob, + URL: { + createObjectURL: (blob) => { + blobs.push(blob); + return "blob:fixture"; + }, + revokeObjectURL() {}, + }, + window: { + setInterval: (fn) => timers.push(fn), + addEventListener: (key, fn) => { + events[key] = fn; + }, + }, + history: { replaceState() {} }, + location: { hash: "" }, + setTimeout: (fn) => fn(), + }; + vm.runInNewContext(fs.readFileSync("theme/js/analytics.js", "utf8"), context); + return { + document, + blobs, + timers, + events, + $: (selector) => document.querySelector(selector), + $$: (selector) => document.querySelectorAll(selector), + }; +} +module.exports = setup; diff --git a/tests/analytics-js.test.cjs b/tests/analytics-js.test.cjs index ad6cfdf1..14b9bb31 100644 --- a/tests/analytics-js.test.cjs +++ b/tests/analytics-js.test.cjs @@ -1,103 +1,199 @@ -/* Synthetic DOM/time tests; no browser or third-party dependencies required. */ +/* Synthetic data/model/DOM tests. Browser layout is tested separately. */ const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const vm = require("node:vm"); -const code = fs.readFileSync("theme/js/analytics.js", "utf8"); -const generated = Date.parse("2026-01-01T06:23:00Z"); -const threeDays = 3 * 24 * 60 * 60 * 1000; +const M = require("../theme/js/analytics-model.js"); +const setup = require("./analytics-dom.cjs"); +const fixture = JSON.parse( + fs.readFileSync("tests/fixtures/analytics-explorer.json"), +); -function run(age, available = true) { - let now = generated + age; - const notice = { hidden: false }; - let interval; - let visibility; - vm.runInNewContext(code, { - Date: { parse: Date.parse, now: () => now }, - Number, - window: { - setInterval: (fn) => { - interval = fn; - }, - }, +test("every preset retains independent whole-period users and dates", () => { + const windows = M.windows(fixture); + assert.deepEqual(Object.keys(windows).sort(), ["30", "7", "90"]); + for (const [days, window] of Object.entries(windows)) { + assert.equal(M.series(window, "active_users", true).length, Number(days)); + assert.equal( + window.summary.active_users, + fixture.windows[days].summary.active_users, + ); + assert.notEqual( + window.summary.active_users, + window.daily_history.reduce((s, r) => s + r.active_users, 0), + ); + const series = M.series(window, "pageviews", true); + assert.equal( + series[0].previousDate, + window.comparison.reporting_period.start, + ); + assert.equal( + series.at(-1).previousDate, + window.comparison.reporting_period.end, + ); + } +}); +test("missing, zero and absent legacy capabilities stay distinct", () => { + const window = structuredClone(fixture.windows["7"]); + window.daily_history[0].pageviews = 0; + window.daily_history.splice(1, 1); + const rows = M.series(window, "pageviews", false); + assert.equal(rows[0].value, 0); + assert.equal(rows[1].value, null); + assert.ok(rows.every((row) => row.previous === null)); + const legacy = JSON.parse( + fs.readFileSync("tests/fixtures/analytics-v1.json"), + ); + assert.deepEqual(Object.keys(M.windows(legacy)), ["30"]); + assert.equal(M.windows(legacy)["30"].comparison, null); + assert.deepEqual(M.windows({ status: "unavailable" }), {}); +}); +test("sorting, search and shares use published rows and full panel totals", () => { + const panel = fixture.windows["30"].breakdowns.countries; + const rows = M.rows(panel, "brazil", "label", "asc"); + assert.equal(rows.length, 1); + assert.equal(rows[0].label, "Brazil"); + assert.equal(rows[0].share, (panel.rows[0].value / panel.total) * 100); + assert.deepEqual(M.rows({ status: "withheld" }), []); + assert.equal(M.rows(panel, "no such country").length, 0); + assert.ok(M.rows(panel).some((row) => row.other)); + const sorted = M.rows(panel, "", "value", "asc"); + assert.ok(sorted.every((row, i) => !i || sorted[i - 1].value <= row.value)); +}); +test("CSV quotes cells and neutralizes spreadsheet formulas", () => { + const csv = M.csv([ + ["=cmd()", "+1", "-1", "@cmd", " \t=cmd", 'a,"b"', "/projects/"], + ]); + assert.ok(csv.startsWith("\ufeff")); + assert.ok(csv.includes('"\'=cmd()"')); + assert.ok(csv.includes('"\'+1"')); + assert.ok(csv.includes('"\'-1"')); + assert.ok(csv.includes('"\'@cmd"')); + assert.ok(csv.includes('"a,""b"""')); + assert.ok(csv.includes('"/projects/"')); +}); +test("comparison labels do not invent growth from zero or missing baselines", () => { + assert.match(M.change(2, 0), /No percentage baseline/); + assert.match(M.change(2, null), /No comparison/); + assert.equal(M.change(50, 100), "−50.0%"); + assert.equal(M.change(10001, 10000), "Less than 0.1% change"); +}); +test("actual explorer script switches windows, metrics, reports and tables", async () => { + const page = setup(); + assert.equal(page.$("#report-overview").hidden, false); + assert.equal(page.$("#report-countries").hidden, true); + const selector = page.$("#analytics-window"); + selector.value = "7"; + selector.dispatch("change"); + assert.equal( + page.$('[data-metric="active_users"] .analytics-metric-value').textContent, + M.number(fixture.windows["7"].summary.active_users), + ); + page.$('[data-metric="active_users"]').click(); + assert.equal( + page.$('[data-metric="active_users"]').getAttribute("aria-pressed"), + "true", + ); + assert.equal( + page.$("#analytics-trend-title").textContent, + "Active users over time", + ); + assert.match(page.$("#analytics-chart-subtitle").textContent, /Do not add/); + page.$("#analytics-table-toggle").click(); + assert.equal(page.$("#analytics-series-table").hidden, false); + assert.equal(page.$$("#analytics-series-table tbody tr").length, 7); + const inspect = page.$("#analytics-inspect-date"); + inspect.value = "0"; + inspect.dispatch("input"); + assert.match( + page.$("#analytics-point-detail").textContent, + new RegExp(fixture.windows["7"].reporting_period.start), + ); + const compare = page.$("#analytics-compare"); + compare.checked = false; + compare.dispatch("change"); + assert.equal(page.$("#analytics-previous-legend").hidden, true); + assert.equal(page.$$("#analytics-series-table thead th").length, 2); + page.$('[data-report="countries"]').click(); + assert.equal(page.$("#report-countries").hidden, false); + assert.equal(page.$("#report-overview").hidden, true); + const search = page.$('[data-search="countries"]'); + search.value = "Brazil"; + search.dispatch("input"); + assert.equal(page.$$('[data-table="countries"] tbody tr').length, 1); + page.$('[data-sort="label"][data-panel="countries"]').click(); + assert.equal( + page + .$('[data-sort="label"][data-panel="countries"]') + .parentElement.getAttribute("aria-sort"), + "descending", + ); + page.$('[data-export="countries"]').click(); + const csv = await page.blobs[0].text(); + assert.match(csv, /Brazil/); + assert.match(csv, /fixture/); + assert.match(csv, /Reporting timezone/); + assert.doesNotMatch(csv, /United States/); + search.value = "no such country"; + search.dispatch("input"); + assert.match( + page.$('[data-table="countries"] tbody').textContent, + /No matching/, + ); + selector.value = "90"; + selector.dispatch("change"); + assert.equal( + page.$('[data-metric="pageviews"] .analytics-metric-value').textContent, + M.number(fixture.windows["90"].summary.pageviews), + ); +}); +test("legacy controls cannot select unqueried periods or metric histories", () => { + const page = setup("tests/fixtures/analytics.json"); + assert.equal(page.$("#analytics-window").options[0].disabled, true); + assert.equal(page.$('[data-metric="active_users"]').disabled, true); + assert.equal(page.$("#analytics-compare").disabled, true); + assert.equal(page.$('[data-export="pages"]').disabled, true); + assert.match( + page.$("#analytics-capabilities").textContent, + /successful new refresh/, + ); + const first = setup("tests/fixtures/analytics-v1.json"); + assert.equal(first.$('[data-metric="pageviews"]').disabled, true); + assert.match(first.$("#analytics-chart").textContent, /not available/); + assert.equal(setup("unavailable").timers.length, 0); +}); +test("staleness updates at the exact three-day boundary and on visibility", () => { + let now = Date.parse(fixture.generated_at) + 3 * 86400000; + const notice = {}, + listeners = {}, + timestamp = { dateTime: fixture.generated_at }; + const context = { + OSLAnalytics: { windows: () => ({}) }, + JSON, + Date: { now: () => now, parse: Date.parse }, document: { - querySelector: () => null, getElementById: (id) => - !available - ? null + id === "analytics-report-data" + ? { textContent: JSON.stringify(fixture) } : id === "analytics-stale" ? notice - : { dateTime: new Date(generated).toISOString() }, - addEventListener: (_, fn) => { - visibility = fn; + : timestamp, + addEventListener: (name, fn) => { + listeners[name] = fn; }, }, - }); - return { - notice, - interval, - visibility, - setAge: (age) => { - now = generated + age; + window: { + setInterval: (fn) => { + listeners.timer = fn; + }, }, }; -} - -test("stale only after three days", () => { - assert.equal(run(threeDays).notice.hidden, true); - assert.equal(run(threeDays + 1).notice.hidden, false); -}); - -test("already open and background tabs age without another deployment", () => { - const page = run(0); - page.setAge(threeDays + 1); - page.interval(); - assert.equal(page.notice.hidden, false); - page.setAge(0); - page.visibility(); - assert.equal(page.notice.hidden, true); -}); - -test("unavailable state needs no timer or refresh date", () => { - assert.equal(run(0, false).interval, undefined); -}); - -test("chart switches preserve pressed state and start with available history", () => { - for (const defaultChart of ["daily", "monthly"]) { - const panels = { daily: { hidden: false }, monthly: { hidden: false } }; - const buttons = Object.keys(panels).map((name) => ({ - dataset: { chartTarget: name }, - attributes: { "aria-controls": `analytics-${name}` }, - getAttribute(key) { - return this.attributes[key]; - }, - setAttribute(key, value) { - this.attributes[key] = value; - }, - addEventListener(event, fn) { - this.click = fn; - }, - })); - const switcher = { - hidden: true, - dataset: { defaultChart }, - querySelectorAll: () => buttons, - }; - vm.runInNewContext(code, { - document: { - querySelector: () => switcher, - getElementById: (id) => panels[id.replace("analytics-", "")] || null, - }, - }); - assert.equal(switcher.hidden, false); - assert.equal(panels[defaultChart].hidden, false); - buttons[1].click(); - assert.equal(panels.daily.hidden, true); - assert.equal(panels.monthly.hidden, false); - assert.equal(buttons[0].attributes["aria-pressed"], "false"); - assert.equal(buttons[1].attributes["aria-pressed"], "true"); - buttons[0].click(); - assert.equal(panels.daily.hidden, false); - assert.equal(panels.monthly.hidden, true); - } + vm.runInNewContext(fs.readFileSync("theme/js/analytics.js", "utf8"), context); + assert.equal(notice.hidden, true); + now++; + listeners.timer(); + assert.equal(notice.hidden, false); + now--; + listeners.visibilitychange(); + assert.equal(notice.hidden, true); }); diff --git a/tests/browser_analytics.py b/tests/browser_analytics.py index e821e377..eb06388f 100644 --- a/tests/browser_analytics.py +++ b/tests/browser_analytics.py @@ -10,27 +10,74 @@ def check_chart_controls(page, output, width, mode): - """Check keyboard chart selection and accessible table disclosures.""" - switches = page.locator("[data-chart-switch]") - if switches.count(): - for name in ("monthly", "daily"): - button = page.locator(f'[data-chart-target="{name}"]') + """Exercise genuine controls, not only the presence of a screenshot.""" + if not page.locator("#analytics-window").count(): + return + report = json.loads(page.locator("#analytics-report-data").text_content()) + for days in ("7", "30", "90"): + option = page.locator(f'#analytics-window option[value="{days}"]') + if option.is_disabled(): + continue + page.locator("#analytics-window").select_option(days) + if report.get("windows"): + window = report["windows"][days] + for key, value in window["summary"].items(): + text = page.locator( + f'[data-metric="{key}"] .analytics-metric-value' + ).inner_text() + if text != f"{value:,}": + raise AssertionError("Selected period summary mismatch") + for key in ("pageviews", "active_users", "sessions"): + button = page.locator(f'[data-metric="{key}"]') + if button.is_disabled(): + continue button.focus() page.keyboard.press("Enter") if button.get_attribute("aria-pressed") != "true": - raise AssertionError("Chart control not selected") - if not page.locator(f"#analytics-{name}").is_visible(): - raise AssertionError("Selected chart is hidden") - table = page.locator(f"#analytics-{name} details") - table.locator("summary").focus() - page.keyboard.press("Enter") - if not table.locator("table").is_visible(): - raise AssertionError("Data table is not operable") - page.screenshot( - path=str(output / f"table-{name}-{width}-{mode}.png"), - full_page=True, - ) - page.keyboard.press("Enter") + raise AssertionError("Metric selection failed") + page.locator("#analytics-table-toggle").click() + if not page.locator("#analytics-series-table table").is_visible(): + raise AssertionError("Daily table is hidden") + page.locator("#analytics-table-toggle").click() + page.locator("#analytics-window").select_option("30") + pageviews = page.locator('[data-metric="pageviews"]') + if not pageviews.is_disabled(): + pageviews.click() + page.screenshot( + path=str(output / f"overview-{width}-{mode}.png"), full_page=True + ) + check_dimension_controls(page, output, width, mode) + + +def check_dimension_controls(page, output, width, mode): + """Inspect report navigation, table search/sort and real CSV downloads.""" + for key in ("countries", "devices", "channels", "pages"): + page.locator(f'.analytics-sidebar [data-report="{key}"]').click() + panel = page.locator(f"#report-{key}") + if not panel.is_visible(): + raise AssertionError("Selected report is hidden") + search = page.locator(f'[data-search="{key}"]') + if not search.is_disabled(): + search.fill("no-matching-category-TEST") + if "No matching categories" not in panel.inner_text(): + raise AssertionError("Table search did not update") + search.fill("") + sort = page.locator(f'[data-panel="{key}"][data-sort="label"]') + sort.click() + with page.expect_download() as pending: + page.locator(f'[data-export="{key}"]').click() + download = pending.value + if download.failure(): + raise AssertionError("CSV download failed") + download.save_as(str(output / f"{key}-{width}-{mode}.csv")) + if page.evaluate( + "document.documentElement.scrollWidth > innerWidth + 1" + ): + raise AssertionError(f"Report overflow: {key}, {width}px") + page.screenshot( + path=str(output / f"{key}-{width}-{mode}.png"), full_page=True + ) + page.locator('.analytics-sidebar [data-report="overview"]').click() def check(url: str, output: Path) -> None: @@ -91,11 +138,10 @@ def check(url: str, output: Path) -> None: page.goto(url) if not page.locator("[data-analytics-report]").is_visible(): raise AssertionError("Report requires JavaScript") - for panel in page.locator(".analytics-trend-panel").all(): + for panel in page.locator("[data-section]").all(): if not panel.is_visible(): - raise AssertionError("A chart requires JavaScript") - panel.locator("summary").click() - if not panel.locator("table").is_visible(): + raise AssertionError("A report requires JavaScript") + if not panel.locator("table").first.is_visible(): raise AssertionError("A data table requires JavaScript") context.close() browser.close() diff --git a/tests/fixtures/analytics-explorer.json b/tests/fixtures/analytics-explorer.json new file mode 100644 index 00000000..caad0861 --- /dev/null +++ b/tests/fixtures/analytics-explorer.json @@ -0,0 +1,2277 @@ +{ + "schema_version": 3, + "data_kind": "fixture", + "status": "available", + "source": { + "publisher": "Open Science Labs", + "system": "Google Analytics 4", + "api": "Google Analytics Data API v1beta" + }, + "site": "https://opensciencelabs.org", + "hostnames": ["opensciencelabs.org"], + "timezone": "America/New_York", + "generated_at": "2026-09-16T06:23:00Z", + "reporting_period": { + "start": "2026-08-17", + "end": "2026-09-15" + }, + "history_period": { + "start": "2025-09-01", + "end": "2026-08-31" + }, + "summary": { + "pageviews": 1556, + "sessions": 903, + "active_users": 317 + }, + "monthly_history": [ + { + "month": "2025-09", + "start": "2025-09-01", + "end": "2025-09-30", + "pageviews": 210 + }, + { + "month": "2025-10", + "start": "2025-10-01", + "end": "2025-10-31", + "pageviews": 280 + }, + { + "month": "2025-11", + "start": "2025-11-01", + "end": "2025-11-30", + "pageviews": 0 + }, + { + "month": "2025-12", + "start": "2025-12-01", + "end": "2025-12-31", + "pageviews": 345 + }, + { + "month": "2026-01", + "start": "2026-01-01", + "end": "2026-01-31", + "pageviews": 410 + }, + { + "month": "2026-02", + "start": "2026-02-01", + "end": "2026-02-28", + "pageviews": 490 + }, + { + "month": "2026-03", + "start": "2026-03-01", + "end": "2026-03-31", + "pageviews": 450 + }, + { + "month": "2026-04", + "start": "2026-04-01", + "end": "2026-04-30", + "pageviews": 520 + }, + { + "month": "2026-05", + "start": "2026-05-01", + "end": "2026-05-31", + "pageviews": 640 + }, + { + "month": "2026-06", + "start": "2026-06-01", + "end": "2026-06-30", + "pageviews": 810 + }, + { + "month": "2026-07", + "start": "2026-07-01", + "end": "2026-07-31", + "pageviews": 920 + }, + { + "month": "2026-08", + "start": "2026-08-01", + "end": "2026-08-31", + "pageviews": 1100 + } + ], + "comparison": { + "reporting_period": { + "start": "2026-07-18", + "end": "2026-08-16" + }, + "summary": { + "pageviews": 1230, + "sessions": 710, + "active_users": 267 + } + }, + "daily_history": [ + { + "date": "2026-08-17", + "pageviews": 48 + }, + { + "date": "2026-08-18", + "pageviews": 56 + }, + { + "date": "2026-08-19", + "pageviews": 62 + }, + { + "date": "2026-08-20", + "pageviews": 62 + }, + { + "date": "2026-08-21", + "pageviews": 57 + }, + { + "date": "2026-08-22", + "pageviews": 49 + }, + { + "date": "2026-08-23", + "pageviews": 41 + }, + { + "date": "2026-08-24", + "pageviews": 36 + }, + { + "date": "2026-08-25", + "pageviews": 36 + }, + { + "date": "2026-08-26", + "pageviews": 42 + }, + { + "date": "2026-08-27", + "pageviews": 51 + }, + { + "date": "2026-08-28", + "pageviews": 59 + }, + { + "date": "2026-08-29", + "pageviews": 65 + }, + { + "date": "2026-08-30", + "pageviews": 65 + }, + { + "date": "2026-08-31", + "pageviews": 60 + }, + { + "date": "2026-09-01", + "pageviews": 52 + }, + { + "date": "2026-09-02", + "pageviews": 44 + }, + { + "date": "2026-09-03", + "pageviews": 39 + }, + { + "date": "2026-09-04", + "pageviews": 39 + }, + { + "date": "2026-09-05", + "pageviews": 45 + }, + { + "date": "2026-09-06", + "pageviews": 54 + }, + { + "date": "2026-09-07", + "pageviews": 63 + }, + { + "date": "2026-09-08", + "pageviews": 68 + }, + { + "date": "2026-09-09", + "pageviews": 68 + }, + { + "date": "2026-09-10", + "pageviews": 63 + }, + { + "date": "2026-09-11", + "pageviews": 54 + }, + { + "date": "2026-09-12", + "pageviews": 46 + }, + { + "date": "2026-09-13", + "pageviews": 42 + }, + { + "date": "2026-09-14", + "pageviews": 42 + }, + { + "date": "2026-09-15", + "pageviews": 48 + } + ], + "breakdowns": { + "countries": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 1556, + "other": 144, + "rows": [ + { + "label": "Brazil", + "value": 482 + }, + { + "label": "United States", + "value": 357 + }, + { + "label": "India", + "value": 248 + }, + { + "label": "Germany", + "value": 155 + }, + { + "label": "United Kingdom", + "value": 108 + }, + { + "label": "Canada", + "value": 62 + } + ] + }, + "devices": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 1556, + "other": 469, + "rows": [ + { + "label": "desktop", + "value": 482 + }, + { + "label": "mobile", + "value": 357 + }, + { + "label": "tablet", + "value": 248 + } + ] + }, + "channels": { + "status": "available", + "metric": "sessions", + "minimum_active_users": 10, + "total": 903, + "other": 120, + "rows": [ + { + "label": "Organic Search", + "value": 279 + }, + { + "label": "Direct", + "value": 207 + }, + { + "label": "Referral", + "value": 144 + }, + { + "label": "Organic Social", + "value": 90 + }, + { + "label": "Email", + "value": 63 + } + ] + } + }, + "windows": { + "7": { + "reporting_period": { + "start": "2026-09-09", + "end": "2026-09-15" + }, + "summary": { + "pageviews": 375, + "sessions": 222, + "active_users": 81 + }, + "comparison": { + "reporting_period": { + "start": "2026-09-02", + "end": "2026-09-08" + }, + "summary": { + "pageviews": 296, + "sessions": 175, + "active_users": 69 + }, + "daily_history": [ + { + "date": "2026-09-02", + "pageviews": 38, + "active_users": 13, + "sessions": 22 + }, + { + "date": "2026-09-03", + "pageviews": 44, + "active_users": 15, + "sessions": 26 + }, + { + "date": "2026-09-04", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-09-05", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-09-06", + "pageviews": 45, + "active_users": 15, + "sessions": 27 + }, + { + "date": "2026-09-07", + "pageviews": 39, + "active_users": 13, + "sessions": 23 + }, + { + "date": "2026-09-08", + "pageviews": 32, + "active_users": 11, + "sessions": 19 + } + ] + }, + "daily_history": [ + { + "date": "2026-09-09", + "pageviews": 48, + "active_users": 16, + "sessions": 28 + }, + { + "date": "2026-09-10", + "pageviews": 56, + "active_users": 19, + "sessions": 33 + }, + { + "date": "2026-09-11", + "pageviews": 62, + "active_users": 21, + "sessions": 37 + }, + { + "date": "2026-09-12", + "pageviews": 62, + "active_users": 21, + "sessions": 37 + }, + { + "date": "2026-09-13", + "pageviews": 57, + "active_users": 19, + "sessions": 34 + }, + { + "date": "2026-09-14", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-09-15", + "pageviews": 41, + "active_users": 13, + "sessions": 24 + } + ], + "breakdowns": { + "countries": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 375, + "other": 35, + "rows": [ + { + "label": "Brazil", + "value": 116 + }, + { + "label": "United States", + "value": 86 + }, + { + "label": "India", + "value": 60 + }, + { + "label": "Germany", + "value": 37 + }, + { + "label": "United Kingdom", + "value": 26 + }, + { + "label": "Canada", + "value": 15 + } + ] + }, + "devices": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 375, + "other": 113, + "rows": [ + { + "label": "desktop", + "value": 116 + }, + { + "label": "mobile", + "value": 86 + }, + { + "label": "tablet", + "value": 60 + } + ] + }, + "channels": { + "status": "available", + "metric": "sessions", + "minimum_active_users": 10, + "total": 222, + "other": 31, + "rows": [ + { + "label": "Organic Search", + "value": 68 + }, + { + "label": "Direct", + "value": 51 + }, + { + "label": "Referral", + "value": 35 + }, + { + "label": "Organic Social", + "value": 22 + }, + { + "label": "Email", + "value": 15 + } + ] + }, + "pages": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 365, + "other": 36, + "rows": [ + { + "label": "/", + "value": 113 + }, + { + "label": "/projects/", + "value": 83 + }, + { + "label": "/blog/", + "value": 58 + }, + { + "label": "/about/", + "value": 36 + }, + { + "label": "/analytics/", + "value": 25 + }, + { + "label": "/about/sponsorship/", + "value": 14 + } + ] + } + } + }, + "30": { + "reporting_period": { + "start": "2026-08-17", + "end": "2026-09-15" + }, + "summary": { + "pageviews": 1556, + "sessions": 903, + "active_users": 317 + }, + "comparison": { + "reporting_period": { + "start": "2026-07-18", + "end": "2026-08-16" + }, + "summary": { + "pageviews": 1230, + "sessions": 710, + "active_users": 267 + }, + "daily_history": [ + { + "date": "2026-07-18", + "pageviews": 38, + "active_users": 13, + "sessions": 22 + }, + { + "date": "2026-07-19", + "pageviews": 44, + "active_users": 15, + "sessions": 26 + }, + { + "date": "2026-07-20", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-07-21", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-07-22", + "pageviews": 45, + "active_users": 15, + "sessions": 27 + }, + { + "date": "2026-07-23", + "pageviews": 39, + "active_users": 13, + "sessions": 23 + }, + { + "date": "2026-07-24", + "pageviews": 32, + "active_users": 11, + "sessions": 19 + }, + { + "date": "2026-07-25", + "pageviews": 28, + "active_users": 9, + "sessions": 17 + }, + { + "date": "2026-07-26", + "pageviews": 29, + "active_users": 9, + "sessions": 17 + }, + { + "date": "2026-07-27", + "pageviews": 33, + "active_users": 11, + "sessions": 19 + }, + { + "date": "2026-07-28", + "pageviews": 40, + "active_users": 13, + "sessions": 23 + }, + { + "date": "2026-07-29", + "pageviews": 47, + "active_users": 15, + "sessions": 27 + }, + { + "date": "2026-07-30", + "pageviews": 51, + "active_users": 16, + "sessions": 30 + }, + { + "date": "2026-07-31", + "pageviews": 51, + "active_users": 16, + "sessions": 30 + }, + { + "date": "2026-08-01", + "pageviews": 47, + "active_users": 15, + "sessions": 27 + }, + { + "date": "2026-08-02", + "pageviews": 41, + "active_users": 13, + "sessions": 24 + }, + { + "date": "2026-08-03", + "pageviews": 34, + "active_users": 10, + "sessions": 20 + }, + { + "date": "2026-08-04", + "pageviews": 31, + "active_users": 9, + "sessions": 18 + }, + { + "date": "2026-08-05", + "pageviews": 31, + "active_users": 9, + "sessions": 18 + }, + { + "date": "2026-08-06", + "pageviews": 36, + "active_users": 11, + "sessions": 20 + }, + { + "date": "2026-08-07", + "pageviews": 43, + "active_users": 13, + "sessions": 24 + }, + { + "date": "2026-08-08", + "pageviews": 50, + "active_users": 15, + "sessions": 28 + }, + { + "date": "2026-08-09", + "pageviews": 54, + "active_users": 16, + "sessions": 31 + }, + { + "date": "2026-08-10", + "pageviews": 54, + "active_users": 16, + "sessions": 31 + }, + { + "date": "2026-08-11", + "pageviews": 50, + "active_users": 15, + "sessions": 28 + }, + { + "date": "2026-08-12", + "pageviews": 43, + "active_users": 13, + "sessions": 24 + }, + { + "date": "2026-08-13", + "pageviews": 37, + "active_users": 10, + "sessions": 21 + }, + { + "date": "2026-08-14", + "pageviews": 33, + "active_users": 9, + "sessions": 18 + }, + { + "date": "2026-08-15", + "pageviews": 33, + "active_users": 9, + "sessions": 19 + }, + { + "date": "2026-08-16", + "pageviews": 38, + "active_users": 11, + "sessions": 21 + } + ] + }, + "daily_history": [ + { + "date": "2026-08-17", + "pageviews": 48, + "active_users": 16, + "sessions": 28 + }, + { + "date": "2026-08-18", + "pageviews": 56, + "active_users": 19, + "sessions": 33 + }, + { + "date": "2026-08-19", + "pageviews": 62, + "active_users": 21, + "sessions": 37 + }, + { + "date": "2026-08-20", + "pageviews": 62, + "active_users": 21, + "sessions": 37 + }, + { + "date": "2026-08-21", + "pageviews": 57, + "active_users": 19, + "sessions": 34 + }, + { + "date": "2026-08-22", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-08-23", + "pageviews": 41, + "active_users": 13, + "sessions": 24 + }, + { + "date": "2026-08-24", + "pageviews": 36, + "active_users": 12, + "sessions": 21 + }, + { + "date": "2026-08-25", + "pageviews": 36, + "active_users": 12, + "sessions": 21 + }, + { + "date": "2026-08-26", + "pageviews": 42, + "active_users": 14, + "sessions": 25 + }, + { + "date": "2026-08-27", + "pageviews": 51, + "active_users": 16, + "sessions": 30 + }, + { + "date": "2026-08-28", + "pageviews": 59, + "active_users": 19, + "sessions": 35 + }, + { + "date": "2026-08-29", + "pageviews": 65, + "active_users": 21, + "sessions": 38 + }, + { + "date": "2026-08-30", + "pageviews": 65, + "active_users": 21, + "sessions": 38 + }, + { + "date": "2026-08-31", + "pageviews": 60, + "active_users": 19, + "sessions": 35 + }, + { + "date": "2026-09-01", + "pageviews": 52, + "active_users": 16, + "sessions": 30 + }, + { + "date": "2026-09-02", + "pageviews": 44, + "active_users": 13, + "sessions": 25 + }, + { + "date": "2026-09-03", + "pageviews": 39, + "active_users": 12, + "sessions": 22 + }, + { + "date": "2026-09-04", + "pageviews": 39, + "active_users": 12, + "sessions": 23 + }, + { + "date": "2026-09-05", + "pageviews": 45, + "active_users": 14, + "sessions": 26 + }, + { + "date": "2026-09-06", + "pageviews": 54, + "active_users": 16, + "sessions": 31 + }, + { + "date": "2026-09-07", + "pageviews": 63, + "active_users": 19, + "sessions": 36 + }, + { + "date": "2026-09-08", + "pageviews": 68, + "active_users": 21, + "sessions": 39 + }, + { + "date": "2026-09-09", + "pageviews": 68, + "active_users": 21, + "sessions": 39 + }, + { + "date": "2026-09-10", + "pageviews": 63, + "active_users": 19, + "sessions": 36 + }, + { + "date": "2026-09-11", + "pageviews": 54, + "active_users": 16, + "sessions": 31 + }, + { + "date": "2026-09-12", + "pageviews": 46, + "active_users": 13, + "sessions": 26 + }, + { + "date": "2026-09-13", + "pageviews": 42, + "active_users": 12, + "sessions": 23 + }, + { + "date": "2026-09-14", + "pageviews": 42, + "active_users": 12, + "sessions": 24 + }, + { + "date": "2026-09-15", + "pageviews": 48, + "active_users": 14, + "sessions": 27 + } + ], + "breakdowns": { + "countries": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 1556, + "other": 144, + "rows": [ + { + "label": "Brazil", + "value": 482 + }, + { + "label": "United States", + "value": 357 + }, + { + "label": "India", + "value": 248 + }, + { + "label": "Germany", + "value": 155 + }, + { + "label": "United Kingdom", + "value": 108 + }, + { + "label": "Canada", + "value": 62 + } + ] + }, + "devices": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 1556, + "other": 469, + "rows": [ + { + "label": "desktop", + "value": 482 + }, + { + "label": "mobile", + "value": 357 + }, + { + "label": "tablet", + "value": 248 + } + ] + }, + "channels": { + "status": "available", + "metric": "sessions", + "minimum_active_users": 10, + "total": 903, + "other": 120, + "rows": [ + { + "label": "Organic Search", + "value": 279 + }, + { + "label": "Direct", + "value": 207 + }, + { + "label": "Referral", + "value": 144 + }, + { + "label": "Organic Social", + "value": 90 + }, + { + "label": "Email", + "value": 63 + } + ] + }, + "pages": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 1546, + "other": 142, + "rows": [ + { + "label": "/", + "value": 479 + }, + { + "label": "/projects/", + "value": 355 + }, + { + "label": "/blog/", + "value": 247 + }, + { + "label": "/about/", + "value": 154 + }, + { + "label": "/analytics/", + "value": 108 + }, + { + "label": "/about/sponsorship/", + "value": 61 + } + ] + } + } + }, + "90": { + "reporting_period": { + "start": "2026-06-18", + "end": "2026-09-15" + }, + "summary": { + "pageviews": 5476, + "sessions": 3026, + "active_users": 959 + }, + "comparison": { + "reporting_period": { + "start": "2026-03-20", + "end": "2026-06-17" + }, + "summary": { + "pageviews": 4328, + "sessions": 2392, + "active_users": 800 + }, + "daily_history": [ + { + "date": "2026-03-20", + "pageviews": 38, + "active_users": 13, + "sessions": 22 + }, + { + "date": "2026-03-21", + "pageviews": 44, + "active_users": 15, + "sessions": 26 + }, + { + "date": "2026-03-22", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-03-23", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-03-24", + "pageviews": 45, + "active_users": 15, + "sessions": 27 + }, + { + "date": "2026-03-25", + "pageviews": 39, + "active_users": 13, + "sessions": 23 + }, + { + "date": "2026-03-26", + "pageviews": 32, + "active_users": 11, + "sessions": 19 + }, + { + "date": "2026-03-27", + "pageviews": 28, + "active_users": 9, + "sessions": 17 + }, + { + "date": "2026-03-28", + "pageviews": 29, + "active_users": 9, + "sessions": 17 + }, + { + "date": "2026-03-29", + "pageviews": 33, + "active_users": 11, + "sessions": 19 + }, + { + "date": "2026-03-30", + "pageviews": 40, + "active_users": 13, + "sessions": 23 + }, + { + "date": "2026-03-31", + "pageviews": 47, + "active_users": 15, + "sessions": 27 + }, + { + "date": "2026-04-01", + "pageviews": 51, + "active_users": 16, + "sessions": 30 + }, + { + "date": "2026-04-02", + "pageviews": 51, + "active_users": 16, + "sessions": 30 + }, + { + "date": "2026-04-03", + "pageviews": 47, + "active_users": 15, + "sessions": 27 + }, + { + "date": "2026-04-04", + "pageviews": 41, + "active_users": 13, + "sessions": 24 + }, + { + "date": "2026-04-05", + "pageviews": 34, + "active_users": 10, + "sessions": 20 + }, + { + "date": "2026-04-06", + "pageviews": 31, + "active_users": 9, + "sessions": 18 + }, + { + "date": "2026-04-07", + "pageviews": 31, + "active_users": 9, + "sessions": 18 + }, + { + "date": "2026-04-08", + "pageviews": 36, + "active_users": 11, + "sessions": 20 + }, + { + "date": "2026-04-09", + "pageviews": 43, + "active_users": 13, + "sessions": 24 + }, + { + "date": "2026-04-10", + "pageviews": 50, + "active_users": 15, + "sessions": 28 + }, + { + "date": "2026-04-11", + "pageviews": 54, + "active_users": 16, + "sessions": 31 + }, + { + "date": "2026-04-12", + "pageviews": 54, + "active_users": 16, + "sessions": 31 + }, + { + "date": "2026-04-13", + "pageviews": 50, + "active_users": 15, + "sessions": 28 + }, + { + "date": "2026-04-14", + "pageviews": 43, + "active_users": 13, + "sessions": 24 + }, + { + "date": "2026-04-15", + "pageviews": 37, + "active_users": 10, + "sessions": 21 + }, + { + "date": "2026-04-16", + "pageviews": 33, + "active_users": 9, + "sessions": 18 + }, + { + "date": "2026-04-17", + "pageviews": 33, + "active_users": 9, + "sessions": 19 + }, + { + "date": "2026-04-18", + "pageviews": 38, + "active_users": 11, + "sessions": 21 + }, + { + "date": "2026-04-19", + "pageviews": 45, + "active_users": 13, + "sessions": 25 + }, + { + "date": "2026-04-20", + "pageviews": 52, + "active_users": 15, + "sessions": 29 + }, + { + "date": "2026-04-21", + "pageviews": 56, + "active_users": 16, + "sessions": 32 + }, + { + "date": "2026-04-22", + "pageviews": 56, + "active_users": 16, + "sessions": 32 + }, + { + "date": "2026-04-23", + "pageviews": 52, + "active_users": 15, + "sessions": 29 + }, + { + "date": "2026-04-24", + "pageviews": 45, + "active_users": 13, + "sessions": 25 + }, + { + "date": "2026-04-25", + "pageviews": 39, + "active_users": 10, + "sessions": 21 + }, + { + "date": "2026-04-26", + "pageviews": 35, + "active_users": 9, + "sessions": 19 + }, + { + "date": "2026-04-27", + "pageviews": 36, + "active_users": 9, + "sessions": 20 + }, + { + "date": "2026-04-28", + "pageviews": 41, + "active_users": 11, + "sessions": 22 + }, + { + "date": "2026-04-29", + "pageviews": 48, + "active_users": 13, + "sessions": 27 + }, + { + "date": "2026-04-30", + "pageviews": 55, + "active_users": 15, + "sessions": 30 + }, + { + "date": "2026-05-01", + "pageviews": 59, + "active_users": 16, + "sessions": 33 + }, + { + "date": "2026-05-02", + "pageviews": 58, + "active_users": 16, + "sessions": 33 + }, + { + "date": "2026-05-03", + "pageviews": 54, + "active_users": 15, + "sessions": 30 + }, + { + "date": "2026-05-04", + "pageviews": 47, + "active_users": 13, + "sessions": 26 + }, + { + "date": "2026-05-05", + "pageviews": 41, + "active_users": 10, + "sessions": 22 + }, + { + "date": "2026-05-06", + "pageviews": 38, + "active_users": 9, + "sessions": 20 + }, + { + "date": "2026-05-07", + "pageviews": 38, + "active_users": 9, + "sessions": 21 + }, + { + "date": "2026-05-08", + "pageviews": 43, + "active_users": 11, + "sessions": 24 + }, + { + "date": "2026-05-09", + "pageviews": 50, + "active_users": 13, + "sessions": 28 + }, + { + "date": "2026-05-10", + "pageviews": 57, + "active_users": 15, + "sessions": 32 + }, + { + "date": "2026-05-11", + "pageviews": 61, + "active_users": 17, + "sessions": 34 + }, + { + "date": "2026-05-12", + "pageviews": 61, + "active_users": 16, + "sessions": 33 + }, + { + "date": "2026-05-13", + "pageviews": 56, + "active_users": 15, + "sessions": 31 + }, + { + "date": "2026-05-14", + "pageviews": 50, + "active_users": 12, + "sessions": 27 + }, + { + "date": "2026-05-15", + "pageviews": 43, + "active_users": 10, + "sessions": 23 + }, + { + "date": "2026-05-16", + "pageviews": 40, + "active_users": 9, + "sessions": 21 + }, + { + "date": "2026-05-17", + "pageviews": 41, + "active_users": 9, + "sessions": 22 + }, + { + "date": "2026-05-18", + "pageviews": 46, + "active_users": 11, + "sessions": 25 + }, + { + "date": "2026-05-19", + "pageviews": 53, + "active_users": 13, + "sessions": 29 + }, + { + "date": "2026-05-20", + "pageviews": 60, + "active_users": 15, + "sessions": 33 + }, + { + "date": "2026-05-21", + "pageviews": 63, + "active_users": 17, + "sessions": 35 + }, + { + "date": "2026-05-22", + "pageviews": 63, + "active_users": 16, + "sessions": 34 + }, + { + "date": "2026-05-23", + "pageviews": 58, + "active_users": 15, + "sessions": 32 + }, + { + "date": "2026-05-24", + "pageviews": 52, + "active_users": 12, + "sessions": 28 + }, + { + "date": "2026-05-25", + "pageviews": 45, + "active_users": 10, + "sessions": 24 + }, + { + "date": "2026-05-26", + "pageviews": 42, + "active_users": 9, + "sessions": 22 + }, + { + "date": "2026-05-27", + "pageviews": 43, + "active_users": 9, + "sessions": 23 + }, + { + "date": "2026-05-28", + "pageviews": 48, + "active_users": 11, + "sessions": 26 + }, + { + "date": "2026-05-29", + "pageviews": 55, + "active_users": 13, + "sessions": 30 + }, + { + "date": "2026-05-30", + "pageviews": 62, + "active_users": 15, + "sessions": 34 + }, + { + "date": "2026-05-31", + "pageviews": 66, + "active_users": 17, + "sessions": 36 + }, + { + "date": "2026-06-01", + "pageviews": 65, + "active_users": 16, + "sessions": 35 + }, + { + "date": "2026-06-02", + "pageviews": 61, + "active_users": 15, + "sessions": 33 + }, + { + "date": "2026-06-03", + "pageviews": 54, + "active_users": 12, + "sessions": 29 + }, + { + "date": "2026-06-04", + "pageviews": 48, + "active_users": 10, + "sessions": 25 + }, + { + "date": "2026-06-05", + "pageviews": 45, + "active_users": 9, + "sessions": 23 + }, + { + "date": "2026-06-06", + "pageviews": 46, + "active_users": 9, + "sessions": 24 + }, + { + "date": "2026-06-07", + "pageviews": 51, + "active_users": 11, + "sessions": 27 + }, + { + "date": "2026-06-08", + "pageviews": 58, + "active_users": 13, + "sessions": 31 + }, + { + "date": "2026-06-09", + "pageviews": 65, + "active_users": 15, + "sessions": 35 + }, + { + "date": "2026-06-10", + "pageviews": 68, + "active_users": 17, + "sessions": 37 + }, + { + "date": "2026-06-11", + "pageviews": 67, + "active_users": 16, + "sessions": 36 + }, + { + "date": "2026-06-12", + "pageviews": 63, + "active_users": 15, + "sessions": 33 + }, + { + "date": "2026-06-13", + "pageviews": 56, + "active_users": 12, + "sessions": 29 + }, + { + "date": "2026-06-14", + "pageviews": 50, + "active_users": 10, + "sessions": 26 + }, + { + "date": "2026-06-15", + "pageviews": 47, + "active_users": 9, + "sessions": 24 + }, + { + "date": "2026-06-16", + "pageviews": 48, + "active_users": 9, + "sessions": 25 + }, + { + "date": "2026-06-17", + "pageviews": 53, + "active_users": 11, + "sessions": 28 + } + ] + }, + "daily_history": [ + { + "date": "2026-06-18", + "pageviews": 48, + "active_users": 16, + "sessions": 28 + }, + { + "date": "2026-06-19", + "pageviews": 56, + "active_users": 19, + "sessions": 33 + }, + { + "date": "2026-06-20", + "pageviews": 62, + "active_users": 21, + "sessions": 37 + }, + { + "date": "2026-06-21", + "pageviews": 62, + "active_users": 21, + "sessions": 37 + }, + { + "date": "2026-06-22", + "pageviews": 57, + "active_users": 19, + "sessions": 34 + }, + { + "date": "2026-06-23", + "pageviews": 49, + "active_users": 16, + "sessions": 29 + }, + { + "date": "2026-06-24", + "pageviews": 41, + "active_users": 13, + "sessions": 24 + }, + { + "date": "2026-06-25", + "pageviews": 36, + "active_users": 12, + "sessions": 21 + }, + { + "date": "2026-06-26", + "pageviews": 36, + "active_users": 12, + "sessions": 21 + }, + { + "date": "2026-06-27", + "pageviews": 42, + "active_users": 14, + "sessions": 25 + }, + { + "date": "2026-06-28", + "pageviews": 51, + "active_users": 16, + "sessions": 30 + }, + { + "date": "2026-06-29", + "pageviews": 59, + "active_users": 19, + "sessions": 35 + }, + { + "date": "2026-06-30", + "pageviews": 65, + "active_users": 21, + "sessions": 38 + }, + { + "date": "2026-07-01", + "pageviews": 65, + "active_users": 21, + "sessions": 38 + }, + { + "date": "2026-07-02", + "pageviews": 60, + "active_users": 19, + "sessions": 35 + }, + { + "date": "2026-07-03", + "pageviews": 52, + "active_users": 16, + "sessions": 30 + }, + { + "date": "2026-07-04", + "pageviews": 44, + "active_users": 13, + "sessions": 25 + }, + { + "date": "2026-07-05", + "pageviews": 39, + "active_users": 12, + "sessions": 22 + }, + { + "date": "2026-07-06", + "pageviews": 39, + "active_users": 12, + "sessions": 23 + }, + { + "date": "2026-07-07", + "pageviews": 45, + "active_users": 14, + "sessions": 26 + }, + { + "date": "2026-07-08", + "pageviews": 54, + "active_users": 16, + "sessions": 31 + }, + { + "date": "2026-07-09", + "pageviews": 63, + "active_users": 19, + "sessions": 36 + }, + { + "date": "2026-07-10", + "pageviews": 68, + "active_users": 21, + "sessions": 39 + }, + { + "date": "2026-07-11", + "pageviews": 68, + "active_users": 21, + "sessions": 39 + }, + { + "date": "2026-07-12", + "pageviews": 63, + "active_users": 19, + "sessions": 36 + }, + { + "date": "2026-07-13", + "pageviews": 54, + "active_users": 16, + "sessions": 31 + }, + { + "date": "2026-07-14", + "pageviews": 46, + "active_users": 13, + "sessions": 26 + }, + { + "date": "2026-07-15", + "pageviews": 42, + "active_users": 12, + "sessions": 23 + }, + { + "date": "2026-07-16", + "pageviews": 42, + "active_users": 12, + "sessions": 24 + }, + { + "date": "2026-07-17", + "pageviews": 48, + "active_users": 14, + "sessions": 27 + }, + { + "date": "2026-07-18", + "pageviews": 57, + "active_users": 17, + "sessions": 32 + }, + { + "date": "2026-07-19", + "pageviews": 66, + "active_users": 19, + "sessions": 37 + }, + { + "date": "2026-07-20", + "pageviews": 71, + "active_users": 21, + "sessions": 40 + }, + { + "date": "2026-07-21", + "pageviews": 71, + "active_users": 21, + "sessions": 40 + }, + { + "date": "2026-07-22", + "pageviews": 65, + "active_users": 19, + "sessions": 37 + }, + { + "date": "2026-07-23", + "pageviews": 57, + "active_users": 16, + "sessions": 32 + }, + { + "date": "2026-07-24", + "pageviews": 49, + "active_users": 13, + "sessions": 27 + }, + { + "date": "2026-07-25", + "pageviews": 45, + "active_users": 12, + "sessions": 25 + }, + { + "date": "2026-07-26", + "pageviews": 46, + "active_users": 12, + "sessions": 25 + }, + { + "date": "2026-07-27", + "pageviews": 52, + "active_users": 14, + "sessions": 28 + }, + { + "date": "2026-07-28", + "pageviews": 60, + "active_users": 17, + "sessions": 34 + }, + { + "date": "2026-07-29", + "pageviews": 69, + "active_users": 19, + "sessions": 39 + }, + { + "date": "2026-07-30", + "pageviews": 74, + "active_users": 21, + "sessions": 41 + }, + { + "date": "2026-07-31", + "pageviews": 74, + "active_users": 21, + "sessions": 41 + }, + { + "date": "2026-08-01", + "pageviews": 68, + "active_users": 19, + "sessions": 38 + }, + { + "date": "2026-08-02", + "pageviews": 60, + "active_users": 16, + "sessions": 33 + }, + { + "date": "2026-08-03", + "pageviews": 52, + "active_users": 13, + "sessions": 28 + }, + { + "date": "2026-08-04", + "pageviews": 48, + "active_users": 12, + "sessions": 26 + }, + { + "date": "2026-08-05", + "pageviews": 49, + "active_users": 12, + "sessions": 26 + }, + { + "date": "2026-08-06", + "pageviews": 55, + "active_users": 14, + "sessions": 30 + }, + { + "date": "2026-08-07", + "pageviews": 64, + "active_users": 17, + "sessions": 35 + }, + { + "date": "2026-08-08", + "pageviews": 72, + "active_users": 19, + "sessions": 40 + }, + { + "date": "2026-08-09", + "pageviews": 77, + "active_users": 21, + "sessions": 43 + }, + { + "date": "2026-08-10", + "pageviews": 77, + "active_users": 21, + "sessions": 42 + }, + { + "date": "2026-08-11", + "pageviews": 71, + "active_users": 19, + "sessions": 39 + }, + { + "date": "2026-08-12", + "pageviews": 63, + "active_users": 16, + "sessions": 34 + }, + { + "date": "2026-08-13", + "pageviews": 55, + "active_users": 13, + "sessions": 29 + }, + { + "date": "2026-08-14", + "pageviews": 51, + "active_users": 12, + "sessions": 27 + }, + { + "date": "2026-08-15", + "pageviews": 52, + "active_users": 12, + "sessions": 28 + }, + { + "date": "2026-08-16", + "pageviews": 58, + "active_users": 14, + "sessions": 31 + }, + { + "date": "2026-08-17", + "pageviews": 67, + "active_users": 17, + "sessions": 36 + }, + { + "date": "2026-08-18", + "pageviews": 75, + "active_users": 20, + "sessions": 41 + }, + { + "date": "2026-08-19", + "pageviews": 80, + "active_users": 21, + "sessions": 44 + }, + { + "date": "2026-08-20", + "pageviews": 80, + "active_users": 21, + "sessions": 44 + }, + { + "date": "2026-08-21", + "pageviews": 74, + "active_users": 19, + "sessions": 40 + }, + { + "date": "2026-08-22", + "pageviews": 65, + "active_users": 16, + "sessions": 35 + }, + { + "date": "2026-08-23", + "pageviews": 58, + "active_users": 13, + "sessions": 31 + }, + { + "date": "2026-08-24", + "pageviews": 53, + "active_users": 12, + "sessions": 28 + }, + { + "date": "2026-08-25", + "pageviews": 55, + "active_users": 12, + "sessions": 29 + }, + { + "date": "2026-08-26", + "pageviews": 61, + "active_users": 14, + "sessions": 32 + }, + { + "date": "2026-08-27", + "pageviews": 70, + "active_users": 17, + "sessions": 38 + }, + { + "date": "2026-08-28", + "pageviews": 79, + "active_users": 20, + "sessions": 43 + }, + { + "date": "2026-08-29", + "pageviews": 83, + "active_users": 21, + "sessions": 45 + }, + { + "date": "2026-08-30", + "pageviews": 82, + "active_users": 21, + "sessions": 45 + }, + { + "date": "2026-08-31", + "pageviews": 77, + "active_users": 19, + "sessions": 41 + }, + { + "date": "2026-09-01", + "pageviews": 68, + "active_users": 16, + "sessions": 36 + }, + { + "date": "2026-09-02", + "pageviews": 60, + "active_users": 13, + "sessions": 32 + }, + { + "date": "2026-09-03", + "pageviews": 56, + "active_users": 12, + "sessions": 29 + }, + { + "date": "2026-09-04", + "pageviews": 58, + "active_users": 12, + "sessions": 30 + }, + { + "date": "2026-09-05", + "pageviews": 64, + "active_users": 14, + "sessions": 34 + }, + { + "date": "2026-09-06", + "pageviews": 73, + "active_users": 17, + "sessions": 39 + }, + { + "date": "2026-09-07", + "pageviews": 82, + "active_users": 20, + "sessions": 44 + }, + { + "date": "2026-09-08", + "pageviews": 86, + "active_users": 21, + "sessions": 46 + }, + { + "date": "2026-09-09", + "pageviews": 85, + "active_users": 21, + "sessions": 46 + }, + { + "date": "2026-09-10", + "pageviews": 79, + "active_users": 19, + "sessions": 42 + }, + { + "date": "2026-09-11", + "pageviews": 71, + "active_users": 16, + "sessions": 37 + }, + { + "date": "2026-09-12", + "pageviews": 63, + "active_users": 13, + "sessions": 33 + }, + { + "date": "2026-09-13", + "pageviews": 59, + "active_users": 12, + "sessions": 30 + }, + { + "date": "2026-09-14", + "pageviews": 61, + "active_users": 12, + "sessions": 31 + }, + { + "date": "2026-09-15", + "pageviews": 68, + "active_users": 14, + "sessions": 35 + } + ], + "breakdowns": { + "countries": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 5476, + "other": 495, + "rows": [ + { + "label": "Brazil", + "value": 1697 + }, + { + "label": "United States", + "value": 1259 + }, + { + "label": "India", + "value": 876 + }, + { + "label": "Germany", + "value": 547 + }, + { + "label": "United Kingdom", + "value": 383 + }, + { + "label": "Canada", + "value": 219 + } + ] + }, + "devices": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 5476, + "other": 1644, + "rows": [ + { + "label": "desktop", + "value": 1697 + }, + { + "label": "mobile", + "value": 1259 + }, + { + "label": "tablet", + "value": 876 + } + ] + }, + "channels": { + "status": "available", + "metric": "sessions", + "minimum_active_users": 10, + "total": 3026, + "other": 396, + "rows": [ + { + "label": "Organic Search", + "value": 938 + }, + { + "label": "Direct", + "value": 695 + }, + { + "label": "Referral", + "value": 484 + }, + { + "label": "Organic Social", + "value": 302 + }, + { + "label": "Email", + "value": 211 + } + ] + }, + "pages": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 5466, + "other": 495, + "rows": [ + { + "label": "/", + "value": 1694 + }, + { + "label": "/projects/", + "value": 1257 + }, + { + "label": "/blog/", + "value": 874 + }, + { + "label": "/about/", + "value": 546 + }, + { + "label": "/analytics/", + "value": 382 + }, + { + "label": "/about/sponsorship/", + "value": 218 + } + ] + } + } + } + } +} diff --git a/tests/render_analytics_dom.py b/tests/render_analytics_dom.py new file mode 100644 index 00000000..0a022cb0 --- /dev/null +++ b/tests/render_analytics_dom.py @@ -0,0 +1,51 @@ +"""Render labeled fixtures for offline Node tests, never for publication.""" + +import json +import sys + +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from bs4 import Tag +from test_analytics_presentation import PresentationTests + +from scripts.analytics.report import read_snapshot, unavailable + + +def tree(node): + """Serialize markup, not layout or browser behavior.""" + if not isinstance(node, Tag): + return str(node) + return { + "tag": node.name, + "attrs": { + key: " ".join(value) if isinstance(value, list) else value + for key, value in node.attrs.items() + }, + "nodes": [tree(child) for child in node.contents], + } + + +def render(): + """Keep all generated test material in an ignored, unpublished folder.""" + fixtures = {} + for filename in ( + "tests/fixtures/analytics-explorer.json", + "tests/fixtures/analytics.json", + "tests/fixtures/analytics-v1.json", + "unavailable", + ): + report = ( + unavailable() + if filename == "unavailable" + else read_snapshot(Path(filename), allow_fixture=True) + ) + fixtures[filename] = tree(PresentationTests().render(report)) + target = Path(".cache/analytics-dom-fixtures.json") + target.parent.mkdir(exist_ok=True) + target.write_text(json.dumps(fixtures)) + + +if __name__ == "__main__": + render() diff --git a/tests/test_analytics.py b/tests/test_analytics.py index cc8d3c22..4c8747f8 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -75,8 +75,11 @@ def __init__(self, results=None): [([], [55, 90, 25])], ), response( - ["screenPageViews"], - [(["20260914"], [70]), (["20260915"], [50])], + list(report.METRICS), + [ + (["20260914"], [70, 25, 45]), + (["20260915"], [50, 20, 25]), + ], ["date"], ), response( @@ -96,6 +99,8 @@ def __init__(self, results=None): ), ] ) + if results is None: + self.results.extend([None] * 18) def run_report(self, *, request): """Never contact any remote service.""" @@ -103,6 +108,31 @@ def run_report(self, *, request): result = self.results[len(self.requests) - 1] if isinstance(result, Exception): raise result + if result is None: + metrics = [item["name"] for item in request["metrics"]] + dimensions = [item["name"] for item in request["dimensions"]] + # Synthetic continuation for every additional period and page. + labels = { + "country": "Brazil", + "deviceCategory": "desktop", + "sessionDefaultChannelGroup": "Direct", + "pagePath": "/", + } + dims = [] + for name in dimensions: + dims.append( + request["date_ranges"][0]["end_date"].replace("-", "") + if name == "date" + else labels[name] + ) + counts = { + "screenPageViews": 100, + "activeUsers": 20, + "sessions": 40, + } + return response( + metrics, [(dims, [counts[m] for m in metrics])], dimensions + ) return result diff --git a/tests/test_analytics_dashboard.py b/tests/test_analytics_dashboard.py index 485537c3..5f18ec0d 100644 --- a/tests/test_analytics_dashboard.py +++ b/tests/test_analytics_dashboard.py @@ -38,7 +38,7 @@ def test_comparison_has_independent_period_users(self): client.requests[3]["date_ranges"], [{"start_date": "2026-07-18", "end_date": "2026-08-16"}], ) - for request in client.requests[4:]: + for request in client.requests[4:9]: self.assertEqual( request["date_ranges"], client.requests[1]["date_ranges"] ) @@ -194,13 +194,13 @@ def test_each_new_query_failure_preserves_snapshot(self): export.refresh(path, client, SETTINGS, NOW) self.assertEqual(path.read_bytes(), LEGACY.read_bytes()) export.refresh(path, FakeClient(), SETTINGS, NOW) - self.assertEqual(report.read_snapshot(path)["schema_version"], 2) + self.assertEqual(report.read_snapshot(path)["schema_version"], 3) def test_missing_days_and_empty_panels_not_fabricated(self): """Explicit zeros survive, while absent dates stay absent from JSON.""" client = FakeClient() client.results[4] = response( - ["screenPageViews"], [(["20260915"], [0])], ["date"] + list(report.METRICS), [(["20260915"], [0, 0, 0])], ["date"] ) client.results[5] = response( ["screenPageViews", "activeUsers"], [], ["country"] @@ -235,9 +235,7 @@ def test_legacy_preview_keeps_document_and_timestamp(self): self.assertEqual(result, original) page = presentation_tests.PresentationTests().render(original) self.assertIn("predates the expanded dashboard", page.get_text()) - self.assertEqual( - len(page.select(".analytics-breakdown .analytics-panel-empty")), 3 - ) + self.assertEqual(len(page.select("[data-panel-status]")), 4) def test_new_nested_schema_rejects_invalid_or_private_data(self): """Reject invalid dates, counts, labels and private fields.""" @@ -331,23 +329,19 @@ def test_accessible_controls_and_tables_match_fixture(self): self.assertFalse( page.find(id=control["aria-controls"]).has_attr("hidden") ) - self.assertTrue( - page.select_one("[data-chart-switch]").has_attr("hidden") - ) + self.assertTrue(page.select_one("[data-enhance]").has_attr("hidden")) for row, data in zip( - page.select("#daily-table tbody tr"), + page.select("#analytics-series-table tbody tr"), fixture["daily_history"], strict=True, ): - self.assertEqual(row.select_one("time")["datetime"], data["date"]) + self.assertEqual(row.select_one("th").get_text(), data["date"]) self.assertEqual( row.select_one("td").get_text(), f'{data["pageviews"]:,}' ) view = presentation.dashboard(fixture) for panel in view["panels"]: - table = page.select_one( - f'.analytics-breakdown--{panel["key"]} table' - ) + table = page.select_one(f'[data-table="{panel["key"]}"]') for row, data in zip( table.select("tbody tr"), panel["display_rows"], strict=True ): diff --git a/tests/test_analytics_explorer.py b/tests/test_analytics_explorer.py new file mode 100644 index 00000000..385b1dd2 --- /dev/null +++ b/tests/test_analytics_explorer.py @@ -0,0 +1,216 @@ +"""Multi-period query and public-route safety tests, with no credentials.""" + +import copy +import json +import tempfile +import unittest + +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +from jsonschema import ValidationError +from test_analytics import NOW, SETTINGS, FakeClient, response + +from scripts.analytics import export, report +from scripts.analytics.routes import public_routes + +FIXTURE = Path("tests/fixtures/analytics-explorer.json") + + +class ExplorerTests(unittest.TestCase): + """Every selectable period must have independently measured totals.""" + + def test_all_windows_queries_and_compatibility(self): + """Never sum daily users or re-label a 30-day summary as 90 days.""" + client = FakeClient() + result = export.collect(client, SETTINGS, NOW) + self.assertEqual(set(result["windows"]), {"7", "30", "90"}) + self.assertEqual(result["summary"], result["windows"]["30"]["summary"]) + for days, window in result["windows"].items(): + self.assertEqual( + window["reporting_period"], + report.window_period(NOW, result["timezone"], int(days)), + ) + for data in [window, window["comparison"]]: + matches = [ + request + for request in client.requests + if not request["dimensions"] + and request["metrics"] + == [{"name": m} for m in report.METRICS] + and request["date_ranges"] + == [ + { + "start_date": data["reporting_period"]["start"], + "end_date": data["reporting_period"]["end"], + } + ] + ] + self.assertEqual(len(matches), 1) + self.assertNotEqual( + sum( + row["active_users"] + for row in result["windows"]["30"]["daily_history"] + ), + result["summary"]["active_users"], + ) + self.assertEqual(len(client.requests), 26) + + def test_calendar_presets_across_leap_dst_and_year(self): + """All six windows are inclusive property-local dates.""" + for now, zone in [ + (datetime(2024, 3, 1, 3, tzinfo=timezone.utc), "America/New_York"), + ( + datetime(2026, 11, 2, 6, tzinfo=timezone.utc), + "America/New_York", + ), + ( + datetime(2026, 1, 1, 0, tzinfo=timezone.utc), + "Pacific/Kiritimati", + ), + ]: + for days in report.WINDOW_PRESETS: + current = report.window_period(now, zone, days) + previous = report.previous_period(current) + for period in (current, previous): + self.assertEqual( + ( + datetime.fromisoformat(period["end"]) + - datetime.fromisoformat(period["start"]) + ).days + + 1, + days, + ) + self.assertEqual( + ( + datetime.fromisoformat(current["start"]) + - datetime.fromisoformat(previous["end"]) + ).days, + 1, + ) + with self.assertRaises(ValueError): + report.window_period(NOW, "UTC", 14) + + def test_pages_filtered_at_api_and_response_boundaries(self): + """Only exact known public paths can enter a page ranking.""" + client = FakeClient() + with patch.object( + export, "public_routes", return_value=["/", "/projects/"] + ): + export.collect(client, SETTINGS, NOW) + for request in client.requests: + if request["dimensions"] != [{"name": "pagePath"}]: + continue + filters = request["dimension_filter"]["and_group"]["expressions"] + self.assertEqual( + filters[-1]["filter"], + { + "field_name": "pagePath", + "in_list_filter": { + "values": ["/", "/projects/"], + "case_sensitive": True, + }, + }, + ) + self.assertEqual(filters[0]["filter"]["field_name"], "hostName") + self.assertEqual(filters[1]["filter"]["field_name"], "platform") + for unsafe in [ + "/private/person-123/", + "/?email=private", + "//evil.org/", + "/projects", + "/projects/index.html", + ]: + client = FakeClient() + client.results[8] = response( + ["screenPageViews", "activeUsers"], + [([unsafe], [100, 50])], + ["pagePath"], + ) + with self.subTest(path=unsafe), self.assertRaises(ValueError): + export.collect(client, SETTINGS, NOW) + + def test_route_catalog_uses_mkdocs_urls_not_visitor_input(self): + """Exclude hidden files, assets and unsafe route syntax.""" + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + pages = root / "pages" + pages.mkdir() + for name in ( + "index.md", + "project.md", + ".private.md", + "a?b.md", + "image.svg", + ): + (pages / name).write_text("# Synthetic route fixture") + config = root / "mkdocs.yml" + config.write_text("site_name: Synthetic\ndocs_dir: pages\n") + self.assertEqual(public_routes(config), ["/", "/project/"]) + config.write_text( + "site_name: Synthetic\ndocs_dir: pages\n" + "use_directory_urls: false\n" + ) + self.assertEqual( + public_routes(config), ["/index.html", "/project.html"] + ) + + def test_failures_anywhere_preserve_all_snapshot_versions(self): + """Do not publish partial exports when later requests fail.""" + for source in [ + Path("tests/fixtures/analytics-v1.json"), + Path("tests/fixtures/analytics.json"), + FIXTURE, + ]: + with tempfile.TemporaryDirectory() as folder: + target = Path(folder) / "data.json" + target.write_bytes(source.read_bytes()) + for index in range(8, 26): + client = FakeClient() + client.results[index] = RuntimeError("Synthetic failure") + with ( + self.subTest(version=source.name, index=index), + self.assertRaises(RuntimeError), + ): + export.refresh(target, client, SETTINGS, NOW) + self.assertEqual(target.read_bytes(), source.read_bytes()) + + def test_v3_schema_and_window_invariants(self): + """Reject invalid periods, private fields and unsafe URLs.""" + original = json.loads(FIXTURE.read_text()) + report.validate(original, allow_fixture=True) + mutations = [ + lambda data: data["windows"].pop("7"), + lambda data: data["windows"].update({"14": data["windows"]["7"]}), + lambda data: data["windows"]["7"]["reporting_period"].update( + start="2026-09-01" + ), + lambda data: data["windows"]["90"]["comparison"]["daily_history"][ + 0 + ].update(date="2026-09-15"), + lambda data: data["windows"]["30"]["summary"].update( + active_users=999 + ), + lambda data: data["windows"]["7"]["daily_history"][0].update( + visitor_id="SYNTHETIC" + ), + lambda data: data["windows"]["7"]["breakdowns"]["pages"]["rows"][ + 0 + ].update(label="/?private=query"), + lambda data: data["windows"]["7"]["breakdowns"]["pages"].update( + total=0 + ), + ] + for mutate in mutations: + data = copy.deepcopy(original) + mutate(data) + with ( + self.subTest(mutate=mutate), + self.assertRaises((ValueError, ValidationError)), + ): + report.validate(data, allow_fixture=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_analytics_presentation.py b/tests/test_analytics_presentation.py index 609652f5..b2875974 100644 --- a/tests/test_analytics_presentation.py +++ b/tests/test_analytics_presentation.py @@ -60,7 +60,10 @@ def test_report_table_and_cards_match_json(self): page = self.render(fixture) self.assertIn("TEST FIXTURE", page.get_text()) self.assertEqual( - [node.get_text() for node in page.select(".analytics-metrics dd")], + [ + node.get_text() + for node in page.select(".analytics-metric-value") + ], [f"{value:,}" for value in fixture["summary"].values()], ) rows = page.select("#monthly-table tbody tr") @@ -71,8 +74,8 @@ def test_report_table_and_cards_match_json(self): ) self.assertIn(f'{item["pageviews"]:,}', node.get_text()) self.assertEqual( - [time["datetime"] for time in node.select("td time")], - [item["start"], item["end"]], + node.select_one("td").get_text(), + f'{item["start"]} \N{EN DASH} {item["end"]}', ) self.assertIsNotNone(page.select_one("table caption")) self.assertEqual( @@ -90,9 +93,9 @@ def test_zero_chart_and_stale_state(self): for month in fixture["monthly_history"]: month["pageviews"] = 0 page = self.render(fixture, stale=True) - bars = page.select(".analytics-bar") + bars = page.select(".analytics-month-track i") self.assertEqual(len(bars), 12) - self.assertTrue(all(float(bar["height"]) == 0 for bar in bars)) + self.assertTrue(all(bar["style"] == "height: 0.0%" for bar in bars)) notice = page.select_one("#analytics-stale") self.assertFalse(notice.has_attr("hidden")) self.assertIn("Stale data", notice.get_text()) diff --git a/tests/test_analytics_preview.py b/tests/test_analytics_preview.py new file mode 100644 index 00000000..86f524a9 --- /dev/null +++ b/tests/test_analytics_preview.py @@ -0,0 +1,106 @@ +"""Exercise the real Makim preview CLI without starting a web server.""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = "tests/fixtures/analytics-explorer.json" +SNAPSHOT = ROOT / ".cache/analytics/data.json" + + +class PreviewTaskTests(unittest.TestCase): + """Keep automatic synthetic analytics confined to local preview.""" + + def run_preview(self, flags=(), inherited=None): + """Capture the task's MkDocs invocation, not a mocked YAML render.""" + previous = SNAPSHOT.read_bytes() if SNAPSHOT.exists() else None + with tempfile.TemporaryDirectory() as folder: + directory = Path(folder) + captured = directory / "invocation.json" + executable = directory / "mkdocs" + executable.write_text( + f"#!{sys.executable}\n" + "import json, os, pathlib, sys\n" + "pathlib.Path(os.environ['PREVIEW_TEST_OUTPUT']).write_text(" + "json.dumps({'args': sys.argv[1:], 'fixture': " + "os.environ.get('ANALYTICS_PREVIEW_FIXTURE')}))\n" + ) + executable.chmod(0o755) + env = { + **os.environ, + "PATH": f"{directory}{os.pathsep}{os.environ['PATH']}", + "PREVIEW_TEST_OUTPUT": str(captured), + } + env.pop("ANALYTICS_PREVIEW_FIXTURE", None) + if inherited is not None: + env["ANALYTICS_PREVIEW_FIXTURE"] = inherited + result = subprocess.run( + [sys.executable, "-m", "makim", "pages.preview", *flags], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual( + result.returncode, 0, result.stdout + result.stderr + ) + invocation = json.loads(captured.read_text()) + current = SNAPSHOT.read_bytes() if SNAPSHOT.exists() else None + self.assertEqual(current, previous) + self.assertEqual( + invocation["args"], + ["serve", "--watch", "pages", "--watch", "theme"], + ) + return invocation, result.stdout + + def test_default_enables_labeled_explorer_fixture(self): + """Select the known preview fixture, not inherited data.""" + for inherited in (None, "not-the-preview-fixture.json"): + with self.subTest(inherited=inherited): + invocation, output = self.run_preview(inherited=inherited) + self.assertEqual(invocation["fixture"], FIXTURE) + self.assertIn("TEST FIXTURE", output) + report = json.loads((ROOT / FIXTURE).read_text()) + self.assertEqual(report["data_kind"], "fixture") + + def test_opt_out_clears_even_inherited_fixture(self): + """The flag delegates to the hook's saved snapshot/unavailable path.""" + for inherited in (None, FIXTURE): + with self.subTest(inherited=inherited): + invocation, output = self.run_preview( + flags=("--no-analytics-fixture",), inherited=inherited + ) + self.assertIsNone(invocation["fixture"]) + self.assertIn("saved snapshot", output) + self.assertNotIn("TEST FIXTURE", output) + + def test_fixture_is_preview_scoped_and_pre_build_still_optional(self): + """No shared environment or production task opts into test data.""" + config = yaml.safe_load((ROOT / ".makim.yaml").read_text()) + pages = config["groups"]["pages"] + tasks = pages["tasks"] + self.assertEqual( + tasks["preview"]["hooks"]["pre-run"], + [ + { + "task": "pages.pre-build", + "if": "${{ args.run_pre_build }}", + } + ], + ) + del tasks["preview"] + self.assertNotIn("ANALYTICS_PREVIEW_FIXTURE", yaml.safe_dump(config)) + + +if __name__ == "__main__": + unittest.main() diff --git a/theme/analytics.html b/theme/analytics.html index 4c523b36..51461214 100644 --- a/theme/analytics.html +++ b/theme/analytics.html @@ -1,148 +1,83 @@ {% extends "base.html" %} - {% block header_extra %} + -{% endblock header_extra %} - -{% macro trend(graph, id, title, monthly=false) %} -
-

{{ title }}

Pageviews · zero-based scale
-

Requested dates: {{ graph.period.start }} through {{ graph.period.end }}, inclusive.

- {% if graph.has_data %} -
- -
Exact values below. Gaps mean not reported, not zero.{% if monthly %} The current month is excluded.{% endif %}
-
- {% else %} -

{{ 'Completed-month' if monthly else 'Daily' }} history is not available in this snapshot. No values have been inferred.

- {% endif %} -
- View {{ 'monthly' if monthly else 'daily' }} data table -
- - - {% if monthly %}{% endif %} - {% for point in graph.slots %}{% if monthly %}{% endif %}{% endfor %} -
{{ title }}. Dates use the reporting timezone shown above.
{{ 'Month' if monthly else 'Date' }}Reporting datesPageviews
{{ '{:,}'.format(point.value) if point.value is not none else 'Not reported' }}
-
-
-
-{% endmacro %} +{% endblock %} {% block content %} {% set report = config.extra.analytics_report %} {% set view = config.extra.analytics_view %} -
-
-
-

Open Science Labs / Transparency

-

Audience & reach

-

A shared view of the people discovering open science.

-

OSL-published data · Sourced from Google Analytics

-
- +
+
+

Analytics

Open Science Labs Website reports

+
- {% if report.data_kind == 'fixture' %} -

TEST FIXTURE — LOCAL PREVIEW ONLY. Every figure below is synthetic, not OSL traffic.

- {% endif %} + {% if report.data_kind == 'fixture' %}

TEST FIXTURE — LOCAL PREVIEW ONLY. Synthetic figures, not OSL traffic.

{% endif %} {% if report.status == 'available' %} -
-
Last 30 completed days

through

- -
-

Stale data. The last successful refresh was more than three days ago. These are the last available figures, not a current report.

- - {% for notice in view.notices %}

{{ notice }}

{% endfor %} +
+ +
+
+

Reports overview

Web only · {{ report.hostnames | join(', ') | e }}

+
+ +

+
+
+
Timezone: {{ report.timezone | e }}Last successful refresh (UTC):
+

Stale data. This successful snapshot is more than three days old.

+ +
{% for notice in view.notices %}

{{ notice }}

{% endfor %}
-
-

The last 30 days, at a glance

What do these numbers mean?
-
- {% for metric in view.metrics %} -
-
{{ metric.label }}
{{ '{:,}'.format(metric.value) }}
-

{{ metric.text }}

- {% if metric.previous is not none %}

Previous 30 days: {{ '{:,}'.format(metric.previous) }}

{% endif %} -

{{ metric.definition }}

+
+ {% for metric in view.metrics %} +

{{ metric.text }}

{{ metric.definition }}

+ {% endfor %}
- {% endfor %} -
- {% if report.get('comparison') %}

Compared with {{ report.comparison.reporting_period.start }} through {{ report.comparison.reporting_period.end }}, inclusive, in {{ report.timezone | e }}. A zero baseline has no percentage comparison.

{% endif %} -
+

{% if report.get('comparison') %}Compared with {{ report.comparison.reporting_period.start }} – {{ report.comparison.reporting_period.end }}. All dates inclusive.{% else %}Comparison not available in this snapshot.{% endif %}

- +
+
+

Pageviews over time

All website traffic in the selected reporting window

+
+ {% if view.daily.has_data %} + + {% else %}

Daily history is not available in this snapshot. See the completed-month history below.

{% endif %} +
+ + +
{% for point in view.daily.slots %}{% endfor %}
Daily pageviews · {{ report.timezone | e }}. Not reported is not zero.
DatePageviews
{{ point.date }}{{ '{:,}'.format(point.value) if point.value is not none else 'Not reported' }}
+
+ +

12-month pageview history

{{ report.history_period.start }} – {{ report.history_period.end }} · completed months only, independent of the date selector

{% for point in view.monthly.slots %}{% endfor %}
Exact completed-month pageviews. Missing months are not reported, not zero.
MonthReporting datesPageviews
{{ point.date }}{{ point.start }} – {{ point.end }}{{ '{:,}'.format(point.value) if point.value is not none else 'Not reported' }}
+
-
-

Audience overview

Across borders. Across devices.

Same 30-day reporting window
-
{% for panel in view.panels %} -
-

{{ panel.key }}

{{ panel.title }}

{{ panel.description }}

- {% if panel.status == 'available' and panel.total > 0 %} -
    - {% for row in panel.display_rows %} -
  1. {{ row.label | e }}{{ row.share_label }}
    {{ '{:,}'.format(row.value) }} {{ panel.metric }}
  2. - {% endfor %} -
-

Share of {{ '{:,}'.format(panel.total) }} {{ panel.metric }} returned for this breakdown.

-
View {{ panel.key }} data table
{% for row in panel.display_rows %}{% endfor %}
{{ panel.description }} · {{ report.reporting_period.start }} through {{ report.reporting_period.end }}
Category{{ panel.metric | capitalize }}Share
{{ row.label | e }}{{ '{:,}'.format(row.value) }}{{ row.share_label }}
- {% elif panel.status == 'available' %} -

No measured {{ panel.metric }} were returned for this breakdown. This is not a count of all actual visits.

- {% elif panel.status == 'withheld' %} -

Not published: Google Analytics signaled a privacy threshold or access restriction for this breakdown. No values have been inferred.

- {% else %} -

Available after the next successful dashboard refresh. This retained snapshot does not include the breakdown yet.

- {% endif %} +
+

{{ {'countries':'Countries','channels':'Traffic acquisition','devices':'Devices','pages':'Pages'}[panel.key] }}

{{ panel.description }} · selected reporting window

+
+ +

{% if panel.status == 'missing' %}This snapshot does not include this report. Available after a successful new refresh.{% elif panel.status == 'withheld' %}Google Analytics restricted this breakdown; no values have been inferred.{% elif panel.total == 0 %}No measured {{ panel.metric }} were returned.{% else %}{{ '{:,}'.format(panel.total) }} {{ panel.metric }} returned for this breakdown.{% endif %}

+
{% for row in panel.display_rows %}{% endfor %}
{{ panel.description }}. Shares include grouped categories.
Share
{{ row.label | e }}{{ '{:,}'.format(row.value) }}{{ row.share_label }}
+

{% if panel.key == 'pages' %}Only known public OSL routes are included; unknown paths and query strings are never published. {% elif panel.key == 'countries' %}Country is approximate activity location, not nationality. {% endif %}Up to 10 named categories with at least 10 active users each. Smaller and remaining categories are grouped. User counts are not summed across categories.

{% endfor %} +
-

Up to 10 named categories per panel, each with at least 10 active users over the full period. Smaller, remaining and unclassified categories are combined as “Other / unknown”. No distinct-user counts are added across categories. Country is approximate activity location, not nationality.

-
- {% else %} -
A transparent start

Analytics data is not available yet

We have not published a successful GA4 report. Audience figures, dates and breakdowns will appear after the first refresh. Missing data does not mean zero visits.

Scheduled daily at 06:23 UTC · Aggregate data only

- {% endif %} - -
-

Behind the numbers

Measured traffic, not the whole community.

Consent choices and blockers can exclude visits. Recent figures may change during processing. These are website measurements, not a guarantee of sponsorship reach.

-
Read our methodology & limitations
{{ page.content }}
-
- +
+ {% else %}

Analytics data is not available yet

The first successful refresh will populate these reports. Missing data does not mean zero visits.

Refreshes are scheduled daily at 06:23 UTC.

{% endif %} +

OSL-published data sourced from Google Analytics. Consent choices and blockers can exclude visits; recent figures may change during processing.

Methodology, reporting scope & limitations{{ page.content }}
+
-{% endblock content %} +{% endblock %} diff --git a/theme/css/analytics.css b/theme/css/analytics.css index 8834b0f2..f54cd3f1 100644 --- a/theme/css/analytics.css +++ b/theme/css/analytics.css @@ -1,591 +1,867 @@ -/* Self-contained dashboard; shared OSL light/dark tokens, no chart library. */ -.analytics-dashboard { - --analytics-blue: #076f94; - --analytics-violet: #6750bf; - --analytics-green: #16745b; - width: min(1180px, 100%); - margin: 0 auto; - padding: clamp(1.25rem, 4vw, 3.5rem) clamp(0.25rem, 2vw, 1rem) 2rem; +/* GA-style report workspace using OSL's shared light and dark surfaces. */ +.analytics-app { + --analytics-blue: #0b57d0; + --analytics-tint: #e8f0fe; + --analytics-line-muted: #6d7787; color: var(--fg); font-variant-numeric: tabular-nums; + font-size: 0.9rem; + margin: 0 auto; + max-width: 1440px; } -html[data-mode="dim"] .analytics-dashboard { - --analytics-blue: var(--brand-strong); - --analytics-violet: #b8a7ff; - --analytics-green: #7bd9b7; +html[data-mode="dim"] .analytics-app { + --analytics-blue: #a8c7fa; + --analytics-tint: #1b3355; + --analytics-line-muted: #9ba7bb; } @media (prefers-color-scheme: dark) { - html:not([data-mode="lit"]) .analytics-dashboard { - --analytics-blue: var(--brand-strong); - --analytics-violet: #b8a7ff; - --analytics-green: #7bd9b7; + html:not([data-mode="lit"]) .analytics-app { + --analytics-blue: #a8c7fa; + --analytics-tint: #1b3355; + --analytics-line-muted: #9ba7bb; } } -.analytics-dashboard * { +.analytics-app * { box-sizing: border-box; } -.analytics-dashboard [hidden] { +.analytics-app [hidden] { display: none !important; } -.analytics-dashboard h1, -.analytics-dashboard h2, -.analytics-dashboard h3 { - color: var(--heading); - margin: 0; - letter-spacing: -0.025em; -} -.analytics-dashboard h1 { - font-size: clamp(2.15rem, 5vw, 3.6rem); - font-weight: 750; - line-height: 1.1; -} -.analytics-dashboard h1 > span { +.analytics-app a { color: var(--analytics-blue); } -.analytics-dashboard h2 { - font-size: clamp(1.3rem, 2.4vw, 1.65rem); +.analytics-app h1, +.analytics-app h2, +.analytics-app h3 { + color: var(--heading); + letter-spacing: -0.015em; + margin: 0; line-height: 1.3; } -.analytics-dashboard h3 { - font-size: 1.05rem; - line-height: 1.4; +.analytics-app h1 { + font-size: 1.35rem; + font-weight: 650; } -.analytics-dashboard p { - line-height: 1.6; +.analytics-app h2 { + font-size: 1.45rem; + font-weight: 600; } -.analytics-dashboard a { - color: var(--analytics-blue); +.analytics-app h3 { + font-size: 1rem; + font-weight: 600; +} +.analytics-app p { + line-height: 1.5; } -.analytics-header { +.analytics-app-header { display: flex; + align-items: center; justify-content: space-between; + padding: 0 0 1.25rem; + gap: 1rem; + border-bottom: 1px solid var(--border); +} +.analytics-brand { + display: flex; align-items: center; - gap: 2rem; - margin-bottom: 2rem; + gap: 0.8rem; } -.analytics-eyebrow { - display: block; - color: var(--muted); - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - margin: 0 0 0.65rem; +.analytics-brand-icon { + font-size: 2.2rem; + color: var(--analytics-blue); } -.analytics-intro { - font-size: 1.05rem; - margin: 1rem 0 0.4rem; +.analytics-brand p { color: var(--muted); -} -.analytics-source { font-size: 0.78rem; + margin: 0.15rem 0 0; +} +.analytics-header-actions { + display: flex; + gap: 1.2rem; + font-size: 0.8rem; + flex-wrap: wrap; +} +.analytics-header-actions a { + text-decoration: none; +} +.analytics-workspace { + display: grid; + grid-template-columns: 170px minmax(0, 1fr); + gap: 1.5rem; +} +.analytics-sidebar { + border-right: 1px solid var(--border); + padding: 1.8rem 0.8rem 1rem 0; +} +.analytics-nav-label { + font-size: 0.65rem; + letter-spacing: 0.12em; color: var(--muted); - margin: 0; + margin: 0 0 0.8rem 0.7rem; + font-weight: 700; } -.analytics-actions { +.analytics-sidebar nav { display: flex; flex-direction: column; - align-items: center; - gap: 0.9rem; - flex-shrink: 0; + gap: 0.3rem; } -.analytics-dashboard .analytics-button { - display: inline-flex; +.analytics-sidebar nav a { + display: flex; align-items: center; - justify-content: center; - gap: 1.4rem; - padding: 0.8rem 1.2rem; - border: 1px solid var(--analytics-blue); - border-radius: 0.65rem; - color: var(--heading); - background: var(--surface-solid); - font-size: 0.86rem; - font-weight: 650; - text-decoration: none; + gap: 0.7rem; + border-radius: 0 30px 30px 0; + padding: 0.8rem 0.7rem; min-height: 44px; + color: var(--muted); + text-decoration: none; + font-size: 0.84rem; } -.analytics-button:hover { - box-shadow: 0 0 0 2px var(--analytics-blue); -} -.analytics-sponsor { - font-size: 0.8rem; - text-underline-offset: 4px; +.analytics-sidebar nav a > span { + width: 1.2rem; + text-align: center; + font-size: 1.1rem; } -.analytics-period-strip { - padding: 1.1rem 1.4rem; +.analytics-sidebar nav a:hover { background: var(--surface-muted); - border: 1px solid var(--border); - border-radius: 0.8rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem 2rem; - flex-wrap: wrap; } -.analytics-period-strip p { - margin: 0; - font-size: 0.92rem; +.analytics-sidebar nav a[aria-current="page"] { + color: var(--analytics-blue); + background: var(--analytics-tint); font-weight: 650; } -.analytics-period-strip .analytics-eyebrow { - font-size: 0.65rem; - margin-bottom: 0.4rem; +.analytics-sidebar-note { + border-top: 1px solid var(--border); + margin-top: 2rem; + padding: 0.9rem 0.6rem; + color: var(--muted); + font-size: 0.73rem; +} +.analytics-sidebar-note p { + margin: 0.5rem 0; +} +.analytics-status-dot { + display: inline-block; + background: var(--analytics-blue); + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 0.4rem; +} +.analytics-main { + min-width: 0; + padding: 1.5rem 0; } -.analytics-metadata { +.analytics-toolbar { display: flex; - gap: 1rem 1.8rem; - flex-wrap: wrap; - margin: 0; + justify-content: space-between; + align-items: center; + gap: 1rem; } -.analytics-metadata dt { - font-size: 0.65rem; +.analytics-scope { color: var(--muted); - font-weight: 500; + font-size: 0.78rem; + margin: 0.35rem 0; } -.analytics-metadata dd { - font-size: 0.73rem; - margin: 0.25rem 0 0; - overflow-wrap: anywhere; +.analytics-date-controls { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.2rem; } -.analytics-section-heading { +.analytics-date-controls label { + font-size: 0.7rem; + color: var(--muted); display: flex; align-items: center; + gap: 0.6rem; +} +.analytics-date-controls p { + font-size: 0.78rem; + margin: 0.3rem 0; + color: var(--heading); +} +.analytics-app button, +.analytics-app select, +.analytics-app input[type="search"] { + font: inherit; + border: 1px solid var(--border-strong); + background: var(--surface-solid); + color: var(--fg); + border-radius: 6px; + padding: 0.5rem 0.7rem; +} +.analytics-app button, +.analytics-app select { + cursor: pointer; + min-height: 38px; +} +.analytics-app button:hover:not(:disabled) { + background: var(--surface-muted); +} +.analytics-app button:disabled, +.analytics-app select:disabled { + cursor: default; + opacity: 0.65; +} +.analytics-app input[type="checkbox"], +.analytics-app input[type="range"] { + accent-color: var(--analytics-blue); +} +.analytics-app select { + font-size: 0.8rem; + max-width: 100%; +} +.analytics-app input[type="checkbox"] { + width: 16px; + height: 16px; + vertical-align: middle; +} +.analytics-freshness { + display: flex; justify-content: space-between; - gap: 1rem; - margin: 2rem 0 1.1rem; + gap: 0.4rem 1rem; + flex-wrap: wrap; + font-size: 0.68rem; + color: var(--muted); + padding: 0.7rem 0 1rem; } -.analytics-section-heading > a { - font-size: 0.75rem; +.analytics-freshness time { + overflow-wrap: anywhere; +} +.analytics-alert { + font-size: 0.8rem; + border: 1px solid var(--border-strong); + border-left: 3px solid var(--analytics-blue); + background: var(--surface-warm); + border-radius: 5px; + padding: 0.7rem 0.9rem; + margin: 0.5rem 0 1rem; } -.analytics-section-heading .analytics-eyebrow { - margin-bottom: 0.45rem; +.analytics-fixture { + margin-top: 1rem; } .analytics-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1rem; + border: 1px solid var(--border-strong); + border-radius: 10px; + overflow: hidden; + background: var(--surface-solid); + margin-top: 0.25rem; } .analytics-metric { - --metric-color: var(--analytics-blue); - padding: 1.5rem; - background: var(--surface-solid); - border: 1px solid var(--border); - border-top: 3px solid var(--metric-color); - border-radius: 0.8rem; + padding: 0.85rem 1.1rem 1rem; min-width: 0; + border-right: 1px solid var(--border); } -.analytics-metric--active_users { - --metric-color: var(--analytics-violet); +.analytics-metric:last-child { + border-right: 0; } -.analytics-metric--sessions { - --metric-color: var(--analytics-green); +.analytics-app .analytics-metric button { + display: block; + width: 100%; + text-align: left; + background: none; + border: 0; + border-top: 3px solid transparent; + border-radius: 0; + padding: 0.45rem 0 0.4rem; + opacity: 1; + color: var(--heading); } -.analytics-metric dl { - margin: 0; +.analytics-app .analytics-metric button[aria-pressed="true"] { + border-top-color: var(--analytics-blue); + color: var(--analytics-blue); } -.analytics-metric dt { - color: var(--muted); +.analytics-metric-label { + display: block; font-size: 0.85rem; - font-weight: 600; + font-weight: 550; } -.analytics-metric dd { - color: var(--heading); - font-size: clamp(2.2rem, 4.5vw, 3.2rem); +.analytics-metric-value { + display: block; + font-size: clamp(1.85rem, 3.3vw, 2.6rem); + font-weight: 550; + letter-spacing: -0.04em; line-height: 1.3; - font-weight: 720; - letter-spacing: -0.045em; - margin: 0.5rem 0 0.7rem; + margin-top: 0.4rem; overflow-wrap: anywhere; } .analytics-change { - font-size: 0.77rem; - font-weight: 650; - margin: 0; - color: var(--muted); -} -.analytics-change--up { - color: var(--analytics-green); -} -.analytics-change--down { - color: var(--analytics-violet); -} -.analytics-previous { - font-size: 0.73rem; - margin: 0.2rem 0 0; - color: var(--muted); + font-size: 0.74rem; + color: var(--analytics-blue); + margin: 0.25rem 0; } .analytics-definition { - font-size: 0.73rem; + font-size: 0.7rem; color: var(--muted); - padding-top: 0.9rem; - margin: 1rem 0 0; - border-top: 1px solid var(--border); + margin: 0.5rem 0 0; } -.analytics-footnote { +.analytics-comparison-dates { + font-size: 0.7rem; color: var(--muted); - font-size: 0.74rem; - line-height: 1.7; - margin: 0.85rem 0; + margin: 0.6rem 0 1.1rem; } -.analytics-panel { +.analytics-card { + border: 1px solid var(--border-strong); + border-radius: 10px; background: var(--surface-solid); - border: 1px solid var(--border); - border-radius: 0.9rem; - padding: 1.5rem; - min-width: 0; -} -.analytics-trends { - margin-top: 1.8rem; -} -.analytics-trends > .analytics-section-heading { - margin: 0 0 1.5rem; - align-items: flex-start; + overflow: hidden; + margin-bottom: 1.1rem; } -.analytics-chart-switch { +.analytics-card-heading { display: flex; - border-radius: 0.6rem; - border: 1px solid var(--border); - background: var(--surface-muted); - padding: 0.25rem; - gap: 0.2rem; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.1rem 1.2rem 0.7rem; } -.analytics-chart-switch button { - font: inherit; - font-size: 0.76rem; - border: 1px solid transparent; - border-radius: 0.4rem; - padding: 0.55rem 0.8rem; - min-height: 40px; - cursor: pointer; +.analytics-card-heading p { + font-size: 0.73rem; color: var(--muted); - background: transparent; + margin: 0.3rem 0 0; } -.analytics-chart-switch button[aria-pressed="true"] { - background: var(--surface-solid); - border-color: var(--border); - color: var(--heading); - box-shadow: 0 1px 3px #00000010; +.analytics-card-heading a, +.analytics-card-heading button { + font-size: 0.75rem; + white-space: nowrap; } -.analytics-chart-heading { +.analytics-chart-tools { display: flex; - justify-content: space-between; - gap: 1rem; align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; } -.analytics-chart-heading > span { - font-size: 0.7rem; - color: var(--muted); -} -.analytics-trend-panel + .analytics-trend-panel { - margin-top: 1.6rem; +.analytics-chart-tools label { + font-size: 0.72rem; + white-space: nowrap; } -.analytics-trend-panel[hidden] + .analytics-trend-panel { - margin-top: 0; +.analytics-chart-tools button { + font-size: 0.72rem; + padding: 0.35rem 0.5rem; } .analytics-chart { - margin: 0.8rem 0; + padding: 0 1rem; + min-height: 140px; } .analytics-chart svg { + display: block; width: 100%; height: auto; - display: block; overflow: visible; + touch-action: pan-y; +} +.analytics-chart svg text { + fill: var(--muted); + font-size: 12px; + font-family: inherit; } .analytics-gridline { - stroke: var(--border); + stroke: var(--border-strong); stroke-width: 1; - stroke-dasharray: 3 5; -} -.analytics-axis, -.analytics-missing { - font-family: inherit; - font-size: 10px; - fill: var(--muted); } .analytics-line { - fill: none; stroke: var(--analytics-blue); + fill: none; stroke-width: 2.5; - stroke-linejoin: round; vector-effect: non-scaling-stroke; + stroke-linejoin: round; } -.analytics-point { +.analytics-point, +.analytics-line-point { fill: var(--analytics-blue); } -.analytics-bar { - fill: var(--analytics-violet); - opacity: 0.85; +.analytics-previous-line { + stroke: var(--analytics-line-muted); + fill: none; + stroke-width: 1.8; + stroke-dasharray: 5 5; + vector-effect: non-scaling-stroke; +} +.analytics-previous-line-point { + fill: var(--analytics-line-muted); } -.analytics-chart figcaption { +.analytics-cursor { + stroke: var(--analytics-line-muted); + stroke-width: 1; + stroke-dasharray: 2 3; +} +.analytics-chart-legend { + display: flex; + justify-content: center; + gap: 1.4rem; + font-size: 0.68rem; color: var(--muted); - font-size: 0.72rem; + padding: 0 1rem 0.6rem; + flex-wrap: wrap; +} +.analytics-chart-legend i { + display: inline-block; + width: 18px; + border-top: 3px solid var(--analytics-blue); + vertical-align: middle; + margin-right: 0.35rem; +} +.analytics-chart-legend .analytics-legend-previous { + border-top: 2px dashed var(--analytics-line-muted); } -.analytics-audience-grid { +.analytics-inspect { + padding: 0.6rem 1.2rem 1rem; display: grid; - grid-template-columns: 1.25fr 1fr 1fr; - gap: 1rem; + grid-template-columns: auto minmax(70px, 1fr); + gap: 0.4rem 0.8rem; + align-items: center; } -.analytics-panel-description { +.analytics-inspect label { + font-size: 0.7rem; color: var(--muted); - font-size: 0.77rem; - margin: 0.5rem 0 1.3rem; +} +.analytics-inspect input { + width: 100%; +} +.analytics-inspect output { + grid-column: 1/-1; + font-size: 0.75rem; + min-height: 1.2rem; + color: var(--heading); +} +.analytics-overview-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 1rem; +} +.analytics-preview .analytics-card-heading { + align-items: center; +} +.analytics-preview h3 { + font-size: 0.9rem; +} +.analytics-preview a { + font-size: 0.7rem; } .analytics-ranking { + padding: 0 1.2rem; list-style: none; - padding: 0; - margin: 1.2rem 0; + margin: 0.5rem 0 1.1rem; } .analytics-ranking li { - margin-bottom: 1.1rem; + margin-bottom: 0.9rem; } -.analytics-ranking-label { +.analytics-ranking-title { display: flex; align-items: baseline; justify-content: space-between; - gap: 0.8rem; - font-size: 0.8rem; + gap: 1rem; + font-size: 0.78rem; } -.analytics-ranking-label > :first-child { +.analytics-ranking-title > span { overflow-wrap: anywhere; + min-width: 0; } -.analytics-ranking-label > :last-child { - color: var(--muted); +.analytics-ranking-title strong { + font-weight: 550; flex-shrink: 0; - font-size: 0.73rem; } -.analytics-track { - height: 6px; - border-radius: 8px; +.analytics-rank-track { + height: 5px; background: var(--surface-muted); - margin: 0.45rem 0 0.15rem; + margin: 0.35rem 0 0.1rem; + border-radius: 3px; overflow: hidden; } -.analytics-track > span { +.analytics-rank-track span { display: block; height: 100%; background: var(--analytics-blue); - border-radius: inherit; + border-radius: 3px; } -.analytics-breakdown--devices .analytics-track > span { - background: var(--analytics-violet); +.analytics-ranking small { + font-size: 0.65rem; + color: var(--muted); } -.analytics-breakdown--channels .analytics-track > span { - background: var(--analytics-green); +.analytics-detail-ranking .analytics-ranking { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 2rem; + padding-top: 0.6rem; } -.analytics-ranking li:last-child .analytics-track > span { - background: var(--muted); +.analytics-table-tools { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1.2rem; + border-top: 1px solid var(--border); + background: var(--surface-muted); } -.analytics-count { +.analytics-table-tools label { + font-size: 0.7rem; color: var(--muted); - font-size: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + width: min(300px, 100%); } -.analytics-data-table { - border-top: 1px solid var(--border); - padding-top: 0.85rem; - margin-top: 1rem; +.analytics-table-tools input { + width: 100%; + font-size: 0.8rem !important; } -.analytics-dashboard summary { - cursor: pointer; - color: var(--analytics-blue); - font-size: 0.8rem; - font-weight: 600; - padding: 0.3rem 0; - min-height: 36px; +.analytics-table-tools p { + font-size: 0.7rem; + color: var(--muted); + margin: 0; + max-width: 28ch; } -.analytics-table-wrap { +.analytics-table-scroll { + overflow: auto; max-width: 100%; - overflow-x: auto; - margin-top: 0.8rem; + max-height: 380px; } .analytics-table { width: 100%; border-collapse: collapse; - font-size: 0.75rem; + font-size: 0.78rem; + margin: 0; +} +.analytics-table caption { + text-align: left; + padding: 0.5rem 1.2rem; + font-size: 0.7rem; + color: var(--muted); + caption-side: bottom; } .analytics-table th, .analytics-table td { - text-align: left; - padding: 0.65rem 0.5rem; + padding: 0.85rem 1.2rem; border-bottom: 1px solid var(--border); + vertical-align: middle; color: var(--fg); -} -.analytics-table td { text-align: right; } -.analytics-table caption { - caption-side: top; +.analytics-table th:first-child, +.analytics-table td:first-child { text-align: left; - color: var(--muted); - font-size: 0.72rem; - padding: 0.5rem; + overflow-wrap: anywhere; + max-width: 45ch; +} +.analytics-table th[scope="row"] { + font-weight: 500; } .analytics-table thead th { + font-weight: 550; + font-size: 0.72rem; color: var(--muted); - font-weight: 600; + background: var(--surface-muted); + position: sticky; + top: 0; } -.analytics-notice { - background: var(--surface-warm); - border: 1px solid var(--border); - border-left: 3px solid var(--analytics-blue); - border-radius: 0.5rem; - padding: 0.9rem 1.15rem; - font-size: 0.82rem; - margin: 1rem 0; +.analytics-app .analytics-table thead button { + font-size: inherit; + font-weight: inherit; + padding: 0; + border: 0; + background: none; + white-space: nowrap; + min-height: 32px; + opacity: 1; } -.analytics-fixture { - border-left-color: var(--analytics-violet); +.analytics-table tbody tr:hover { + background: var(--surface-muted); +} +.analytics-table td { + font-variant-numeric: tabular-nums; + white-space: nowrap; } -.analytics-panel-empty { +.analytics-panel-status, +.analytics-panel-note { + font-size: 0.73rem; + line-height: 1.6; color: var(--muted); - font-size: 0.83rem; - padding: 1rem; + padding: 0.6rem 1.2rem; + margin: 0; +} +.analytics-panel-note { + border-top: 1px solid var(--border); + padding: 1rem 1.2rem; +} +.analytics-history-bars { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + padding: 1rem 1.2rem; + gap: 0.4rem; + min-width: 600px; +} +.analytics-history { + overflow-x: auto; +} +.analytics-history-bars > div { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + font-size: 0.65rem; + color: var(--muted); +} +.analytics-month-track { + height: 70px; + display: flex; + align-items: flex-end; + width: 60%; background: var(--surface-muted); - border-radius: 0.6rem; + border-radius: 3px; } -.analytics-empty { - border: 1px dashed var(--border-strong); - border-radius: 0.9rem; - padding: clamp(1.4rem, 5vw, 3.5rem); - margin: 2rem 0; - background: var(--surface-solid); +.analytics-month-track i { + background: var(--analytics-blue); + width: 100%; + display: block; + min-height: 0; + border-radius: 3px; } -.analytics-empty > p { - max-width: 55ch; +.analytics-month-value { + font-size: 0.7rem; + color: var(--fg); } -.analytics-methodology { - margin-top: 2rem; +.analytics-history-table { + max-height: 200px; } -.analytics-methodology > div > p:last-child { +.analytics-empty { + padding: 1.2rem; color: var(--muted); - max-width: 90ch; font-size: 0.85rem; - margin-bottom: 0.8rem; + line-height: 1.6; } -.analytics-methodology-content { - margin-top: 1rem; - font-size: 0.86rem; - max-width: 85ch; +.analytics-empty-start { + padding: 3rem 1rem; } -.analytics-methodology-content h2, -.analytics-methodology-content h3 { - margin: 1.4rem 0 0.8rem; +.analytics-empty-start p { + color: var(--muted); + max-width: 65ch; } -.analytics-methodology-content li { - line-height: 1.8; - margin-bottom: 0.65rem; +.analytics-data-footer { + border-top: 1px solid var(--border); + padding: 1.1rem 0; + color: var(--muted); + font-size: 0.75rem; } -.analytics-support { - display: flex; - align-items: center; - justify-content: space-between; - gap: 2rem; - padding: 2rem 0 1rem; +.analytics-data-footer > p { + margin: 0 0 0.5rem; } -.analytics-support h2 { - font-size: 1.3rem; +.analytics-data-footer summary { + cursor: pointer; + padding: 0.5rem 0; + color: var(--analytics-blue); } -.analytics-support p { - color: var(--muted); - font-size: 0.82rem; +.analytics-data-footer details > h2, +.analytics-data-footer details > h3 { + margin: 1.2rem 0 0.6rem; } -.analytics-support .analytics-button { - flex-shrink: 0; +.analytics-data-footer details > p, +.analytics-data-footer details > ul { + max-width: 100ch; + line-height: 1.7; } -.analytics-dashboard a:focus-visible, -.analytics-dashboard button:focus-visible, -.analytics-dashboard summary:focus-visible, -.analytics-table-wrap:focus-visible { +.analytics-app a:focus-visible, +.analytics-app button:focus-visible, +.analytics-app input:focus-visible, +.analytics-app select:focus-visible, +.analytics-app summary:focus-visible, +.analytics-table-scroll:focus-visible { outline: 3px solid var(--analytics-blue); - outline-offset: 4px; + outline-offset: 3px; } -.analytics-sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; +@media (min-width: 1200px) { + .analytics-app { + margin-top: -1rem; + } + .analytics-trend .analytics-card-heading { + align-items: center; + } } -@media (max-width: 900px) { - .analytics-audience-grid { - grid-template-columns: 1fr 1fr; +@media (max-width: 1000px) { + .analytics-workspace { + grid-template-columns: 135px minmax(0, 1fr); + gap: 1rem; + } + .analytics-sidebar nav a { + font-size: 0.77rem; + gap: 0.4rem; } - .analytics-breakdown--countries { - grid-column: 1 / -1; + .analytics-toolbar { + align-items: flex-start; } - .analytics-breakdown--countries .analytics-ranking { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0 2rem; + .analytics-date-controls label { + flex-direction: column; + align-items: flex-end; + gap: 0.2rem; } - .analytics-metadata { - gap: 1rem; + .analytics-trend .analytics-card-heading { + flex-direction: column; + } + .analytics-chart-tools { + justify-content: flex-start; + } + .analytics-chart svg text { + font-size: 16px; + } + .analytics-card-heading { + padding: 1rem; } .analytics-metric { - padding: 1.15rem; + padding: 0.65rem 0.8rem; + } + .analytics-definition { + font-size: 0.68rem; } } -@media (max-width: 600px) { - .analytics-header, - .analytics-support { +@media (max-width: 700px) { + .analytics-workspace { + display: block; + } + .analytics-sidebar { + border-right: 0; + padding: 0.8rem 0; + border-bottom: 1px solid var(--border); + } + .analytics-nav-label, + .analytics-sidebar-note { + display: none; + } + .analytics-sidebar nav { + flex-direction: row; + overflow-x: auto; + gap: 0.2rem; + } + .analytics-sidebar nav a { + flex-shrink: 0; + border-radius: 24px; + font-size: 0.8rem; + padding: 0.55rem 0.8rem; + } + .analytics-sidebar nav a > span { + display: none; + } + .analytics-main { + padding-top: 1rem; + } + .analytics-app-header { + align-items: flex-start; + } + .analytics-header-actions { + gap: 0.5rem; flex-direction: column; + font-size: 0.7rem; + } + .analytics-brand p { + font-size: 0.7rem; + } + .analytics-toolbar { + flex-wrap: wrap; + } + .analytics-date-controls { align-items: flex-start; - gap: 1rem; + max-width: 100%; } - .analytics-actions { + .analytics-date-controls label { flex-direction: row; - flex-wrap: wrap; - gap: 1rem; + align-items: center; } - .analytics-period-strip { - padding: 1rem; + .analytics-toolbar h2 { + font-size: 1.2rem; + } + .analytics-freshness { + font-size: 0.7rem; } - .analytics-metrics, - .analytics-audience-grid { + .analytics-overview-grid { grid-template-columns: 1fr; } .analytics-metric { - padding: 1.2rem; + padding: 0.55rem; } - .analytics-metric dd { - font-size: 2.6rem; + .analytics-metric-label { + font-size: 0.7rem; } - .analytics-section-heading { - align-items: flex-start; - flex-direction: column; - gap: 0.65rem; + .analytics-metric-value { + font-size: 1.9rem; + } + .analytics-change { + font-size: 0.65rem; + } + .analytics-definition { + font-size: 0.65rem; + } + .analytics-chart { + padding: 0 0.4rem; + } + .analytics-chart svg text { + font-size: 23px; + } + .analytics-card-heading { + flex-wrap: wrap; } - .analytics-panel { - padding: 1.1rem; + .analytics-detail-ranking .analytics-ranking { + grid-template-columns: 1fr; } - .analytics-chart-heading { + .analytics-table-tools { align-items: flex-start; flex-direction: column; - gap: 0.3rem; - } - .analytics-breakdown--countries .analytics-ranking { - display: block; + gap: 0.5rem; } - .analytics-chart svg { - min-width: 0; + .analytics-table-tools p { + max-width: none; } - .analytics-axis { - font-size: 26px; + .analytics-table th, + .analytics-table td { + padding: 0.8rem; } - .analytics-chart-switch { - width: 100%; + .analytics-table { + font-size: 0.75rem; } - .analytics-chart-switch button { - flex: 1; + .analytics-app button, + .analytics-app select { min-height: 44px; - padding: 0.5rem; + } + .analytics-chart-tools button, + .analytics-chart-tools label { + font-size: 0.72rem; + } + .analytics-inspect { + padding: 0.6rem 0.8rem 1rem; + } + .analytics-inspect output { + font-size: 0.72rem; + } +} +@media (max-width: 380px) { + .analytics-metrics { + grid-template-columns: 1fr; + } + .analytics-metric { + border-right: 0; + border-bottom: 1px solid var(--border); + padding: 0.7rem 1rem; + } + .analytics-app .analytics-metric button { + display: flex; + align-items: center; + justify-content: space-between; + } + .analytics-metric-value { + margin: 0; + } + .analytics-metric-label { + font-size: 0.85rem; + } + .analytics-definition, + .analytics-change { + font-size: 0.72rem; + } + .analytics-brand-icon { + display: none; } } @media (prefers-reduced-motion: reduce) { - .analytics-dashboard * { + .analytics-app * { scroll-behavior: auto; transition: none; } diff --git a/theme/js/analytics-model.js b/theme/js/analytics-model.js new file mode 100644 index 00000000..173efc22 --- /dev/null +++ b/theme/js/analytics-model.js @@ -0,0 +1,133 @@ +/* Pure report operations, shared by the browser and offline Node tests. */ +((root) => { + const metrics = { + pageviews: "Pageviews", + active_users: "Active users", + sessions: "Sessions", + }; + const dimensions = { + countries: "Countries", + devices: "Devices", + channels: "Acquisition", + pages: "Pages", + }; + const number = (value) => + value == null ? "Not reported" : new Intl.NumberFormat("en").format(value); + function dates(period) { + const values = []; + for ( + let day = Date.parse(period.start + "T00:00:00Z"); + day <= Date.parse(period.end + "T00:00:00Z"); + day += 86400000 + ) + values.push(new Date(day).toISOString().slice(0, 10)); + return values; + } + function windows(report) { + if (report.status !== "available") return {}; + if (report.windows) return report.windows; + return { + 30: { + reporting_period: report.reporting_period, + summary: report.summary, + comparison: report.comparison || null, + daily_history: report.daily_history || [], + breakdowns: report.breakdowns || {}, + }, + }; + } + function change(current, previous) { + if (previous == null) return "No comparison available"; + if (previous === 0) return "No percentage baseline (previously 0)"; + const delta = ((current - previous) / previous) * 100; + if (!delta) return "No change"; + if (Math.abs(delta) < 0.05) return "Less than 0.1% change"; + return `${delta > 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`; + } + function series(window, metric, compare) { + const current = new Map( + window.daily_history.map((row) => [row.date, row[metric]]), + ); + const previous = new Map( + (window.comparison?.daily_history || []).map((row) => [ + row.date, + row[metric], + ]), + ); + const previousDates = window.comparison + ? dates(window.comparison.reporting_period) + : []; + return dates(window.reporting_period).map((date, i) => ({ + date, + value: current.get(date) ?? null, + previousDate: compare ? previousDates[i] : null, + previous: compare ? previous.get(previousDates[i]) ?? null : null, + })); + } + function rows( + panel, + search = "", + sort = "value", + direction = "desc", + otherLabel = "Other / unknown", + ) { + if (!panel || panel.status !== "available") return []; + const values = [ + ...panel.rows, + { label: otherLabel, value: panel.other, other: true }, + ]; + const query = search.trim().toLocaleLowerCase("en"); + return values + .filter((row) => row.label.toLocaleLowerCase("en").includes(query)) + .map((row) => ({ + ...row, + share: panel.total ? (row.value / panel.total) * 100 : 0, + })) + .sort((a, b) => { + const order = + sort === "label" + ? a.label.localeCompare(b.label, "en") + : a.value - b.value; + return ( + (direction === "asc" ? order : -order) || + a.label.localeCompare(b.label, "en") + ); + }); + } + function share(value) { + return value > 0 && value < 0.1 ? "<0.1%" : `${value.toFixed(1)}%`; + } + function csv(records) { + // Quote every cell; neutralize spreadsheet formula injection as well. + return ( + "\ufeff" + + records + .map((row) => + row + .map((value) => { + let cell = String(value ?? ""); + if (/^[\s]*[=+@-]/u.test(cell) || /^[\t\r\n]/u.test(cell)) + cell = "'" + cell; + return '"' + cell.replaceAll('"', '""') + '"'; + }) + .join(","), + ) + .join("\r\n") + + "\r\n" + ); + } + const api = { + metrics, + dimensions, + number, + dates, + windows, + change, + series, + rows, + share, + csv, + }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.OSLAnalytics = api; +})(globalThis); diff --git a/theme/js/analytics.js b/theme/js/analytics.js index dcd7b288..d57e5f9a 100644 --- a/theme/js/analytics.js +++ b/theme/js/analytics.js @@ -1,34 +1,586 @@ -/* Progressive enhancement only: figures, dates and table are static HTML. */ +/* A static, credential-free report explorer. No browser API requests. */ (() => { - const switcher = document.querySelector("[data-chart-switch]"); - if (switcher) { - const buttons = [...switcher.querySelectorAll("[data-chart-target]")]; - const select = (name) => { - buttons.forEach((button) => { - const selected = button.dataset.chartTarget === name; - button.setAttribute("aria-pressed", String(selected)); - document.getElementById(button.getAttribute("aria-controls")).hidden = - !selected; - }); + const M = globalThis.OSLAnalytics; + const source = document.getElementById("analytics-report-data"); + if (!source || !M) return; + const report = JSON.parse(source.textContent); + const windows = M.windows(report); + const refreshed = document.getElementById("analytics-refreshed"); + const stale = document.getElementById("analytics-stale"); + if (refreshed && stale) { + const update = () => { + stale.hidden = + Date.now() - Date.parse(refreshed.dateTime) <= 3 * 86400000; }; - buttons.forEach((button) => - button.addEventListener("click", () => - select(button.dataset.chartTarget), - ), - ); - select(switcher.dataset.defaultChart); - switcher.hidden = false; + update(); + window.setInterval(update, 60000); + document.addEventListener("visibilitychange", update); } - const refreshed = document.getElementById("analytics-refreshed"); - const notice = document.getElementById("analytics-stale"); - if (!refreshed || !notice) return; - const generatedAt = Date.parse(refreshed.dateTime); - if (!Number.isFinite(generatedAt)) return; - const update = () => { - notice.hidden = Date.now() - generatedAt <= 3 * 24 * 60 * 60 * 1000; + if (!windows["30"]) return; + const app = document.querySelector("[data-analytics-report]"); + const $ = (selector) => app.querySelector(selector); + const $$ = (selector) => [...app.querySelectorAll(selector)]; + const state = { + window: "30", + metric: "pageviews", + section: "overview", + table: false, + filters: {}, }; - update(); - // Also age a page that stays open across the three-day boundary. - window.setInterval(update, 60 * 1000); - document.addEventListener("visibilitychange", update); + const current = () => windows[state.window]; + const otherLabel = (key) => + key === "pages" ? "Other public pages" : "Other / unknown"; + const el = (tag, text, cls) => { + const node = document.createElement(tag); + if (text != null) node.textContent = text; + if (cls) node.className = cls; + return node; + }; + const rangeLabel = (period) => `${period.start} – ${period.end}`; + const fullSeries = report.schema_version >= 3; + const selector = $("#analytics-window"); + [...selector.options].forEach((option) => { + option.disabled = !windows[option.value]; + }); + if (!fullSeries) { + $("#analytics-capabilities").hidden = false; + $("#analytics-capabilities").textContent = + "Retained 30-day snapshot. A successful new refresh enables 7/90-day reports, all metric trends, chart comparisons and public-page statistics. Missing features are not zero traffic."; + $("#analytics-compare").checked = false; + $("#analytics-compare").disabled = true; + } + $$("[data-enhance]").forEach((node) => { + node.hidden = false; + }); + $$("[data-sort]").forEach((node) => { + node.disabled = false; + }); + app.classList.add("analytics-enhanced"); + + function navigate(name, focus = false) { + if (name !== "overview" && !Object.hasOwn(M.dimensions, name)) return; + state.section = name; + $$("[data-section]").forEach((node) => { + node.hidden = node.dataset.section !== name; + }); + $$("[data-report]").forEach((node) => { + if (node.dataset.report === name) + node.setAttribute("aria-current", "page"); + else node.removeAttribute("aria-current"); + }); + $("#analytics-view-title").textContent = + name === "overview" ? "Reports overview" : M.dimensions[name]; + if (focus) $("#analytics-view-title").focus({ preventScroll: true }); + } + app.addEventListener("click", (event) => { + const link = event.target.closest("[data-report]"); + if (!link) return; + event.preventDefault(); + navigate(link.dataset.report, true); + history.replaceState(null, "", "#report-" + link.dataset.report); + }); + window.addEventListener("hashchange", () => + navigate(location.hash.replace("#report-", "")), + ); + + function metadataRows(title) { + return [ + ["OSL-published Google Analytics report", title], + ["Data kind", report.data_kind], + ["Site", report.site], + ["Hostnames", report.hostnames.join(", ")], + ["Reporting timezone", report.timezone], + ["Reporting dates", rangeLabel(current().reporting_period)], + ["Last successful refresh (UTC)", report.generated_at], + ]; + } + function download(records, name) { + const url = URL.createObjectURL( + new Blob([M.csv(records)], { type: "text/csv;charset=utf-8" }), + ); + const anchor = el("a"); + anchor.href = url; + anchor.download = `osl-${name}-${state.window}days.csv`; + app.append(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + function metrics() { + const data = current(); + $("#analytics-dates").textContent = rangeLabel(data.reporting_period); + $("#analytics-comparison-dates").textContent = data.comparison + ? `Compared with ${rangeLabel( + data.comparison.reporting_period, + )}. Dates are inclusive. Active users are independently measured for each full period.` + : "Comparison not available in this snapshot."; + $$("[data-metric]").forEach((button) => { + const key = button.dataset.metric; + button.title = `Show the daily ${M.metrics[ + key + ].toLowerCase()} trend in Overview`; + button.disabled = + !fullSeries && (key !== "pageviews" || !data.daily_history.length); + button.setAttribute("aria-pressed", String(key === state.metric)); + button.querySelector(".analytics-metric-value").textContent = M.number( + data.summary[key], + ); + const previous = data.comparison?.summary[key]; + $(`[data-change="${key}"]`).textContent = `${M.change( + data.summary[key], + previous, + )}${previous == null ? "" : ` · previous ${M.number(previous)}`}`; + }); + const notices = []; + if (!data.summary.pageviews) + notices.push( + "No measured pageviews for this scope. Check collection and hostname settings before concluding that nobody visited.", + ); + else if ( + data.daily_history.length && + data.daily_history.reduce((sum, row) => sum + row.pageviews, 0) !== + data.summary.pageviews + ) + notices.push( + "Daily and headline pageviews differ. Processing or reporting differences may be responsible; figures have not been adjusted to force a match.", + ); + $("#analytics-notices").replaceChildren( + ...notices.map((text) => el("p", text, "analytics-alert")), + ); + } + + let chartRows = []; + function chart() { + const data = current(), + compare = $("#analytics-compare").checked && fullSeries; + chartRows = M.series(data, state.metric, compare); + $("#analytics-trend-title").textContent = `${ + M.metrics[state.metric] + } over time`; + $("#analytics-chart-subtitle").textContent = + state.metric === "active_users" + ? "Each point is a daily distinct count. Do not add these to obtain the whole-period total." + : "All website traffic in the selected reporting window · zero-based scale"; + $("#analytics-previous-legend").hidden = !compare; + const target = $("#analytics-chart"); + target.replaceChildren(); + const svgNS = "http://www.w3.org/2000/svg"; + const svgEl = (tag, attrs, text) => { + const node = document.createElementNS(svgNS, tag); + Object.entries(attrs).forEach(([key, value]) => + node.setAttribute(key, String(value)), + ); + if (text != null) node.textContent = text; + return node; + }; + const any = chartRows.some( + (row) => row.value != null || row.previous != null, + ); + const inspect = $("#analytics-inspect-date"); + inspect.max = String(chartRows.length - 1); + inspect.value = String(chartRows.length - 1); + inspect.disabled = !any; + const tbody = $("#analytics-series-table tbody"); + tbody.replaceChildren(); + const header = el("tr"); + [ + "Date", + M.metrics[state.metric], + ...(compare ? ["Previous date", "Previous value"] : []), + ].forEach((label) => { + const th = el("th", label); + th.scope = "col"; + header.append(th); + }); + $("#analytics-series-table thead").replaceChildren(header); + $("#analytics-series-table caption").textContent = `Daily ${M.metrics[ + state.metric + ].toLowerCase()} · ${report.timezone}. Gaps are not reported, not zero.`; + chartRows.forEach((row) => { + const tr = el("tr"); + const th = el("th", row.date); + th.scope = "row"; + tr.append(th, el("td", M.number(row.value))); + if (compare) + tr.append(el("td", row.previousDate), el("td", M.number(row.previous))); + tbody.append(tr); + }); + if (!any) { + target.append( + el( + "p", + "Daily history is not available for this metric and window. No values have been inferred.", + "analytics-empty", + ), + ); + $("#analytics-point-detail").textContent = "No reported daily points"; + inspect.oninput = null; + return; + } + // Both periods share one scale and day index. Gaps split lines. + const values = chartRows + .flatMap((row) => [row.value, row.previous]) + .filter((value) => value != null); + const max = Math.max(1, ...values), + step = 10 ** Math.floor(Math.log10(max)); + const ceiling = + Math.ceil(Math.max(4, Math.ceil(max / step) * step) / 4) * 4; + const width = 900, + height = 290, + left = 78, + right = 875, + top = 24, + bottom = 245; + const x = (index) => + left + (index * (right - left)) / Math.max(1, chartRows.length - 1); + const y = (value) => bottom - (value / ceiling) * (bottom - top); + const svg = svgEl("svg", { + viewBox: `0 0 ${width} ${height}`, + "aria-hidden": "true", + focusable: "false", + }); + for (let tick = 0; tick <= 4; tick++) { + const value = (ceiling * tick) / 4; + svg.append( + svgEl("line", { + x1: left, + x2: right, + y1: y(value), + y2: y(value), + class: "analytics-gridline", + }), + ); + svg.append( + svgEl( + "text", + { x: left - 12, y: y(value) + 4, "text-anchor": "end" }, + new Intl.NumberFormat("en", { notation: "compact" }).format(value), + ), + ); + } + [0, Math.floor((chartRows.length - 1) / 2), chartRows.length - 1].forEach( + (i, labelIndex) => + svg.append( + svgEl( + "text", + { + x: x(i), + y: 280, + "text-anchor": ["start", "middle", "end"][labelIndex], + }, + chartRows[i].date, + ), + ), + ); + function line(key, cls) { + let points = []; + const flush = () => { + if (points.length) + svg.append( + svgEl("polyline", { points: points.join(" "), class: cls }), + ); + points = []; + }; + chartRows.forEach((row, index) => { + if (row[key] == null) flush(); + else { + points.push(`${x(index)},${y(row[key])}`); + svg.append( + svgEl("circle", { + cx: x(index), + cy: y(row[key]), + r: 2, + class: cls + "-point", + }), + ); + } + }); + flush(); + } + if (compare) line("previous", "analytics-previous-line"); + line("value", "analytics-line"); + const cursor = svgEl("line", { + x1: right, + x2: right, + y1: top, + y2: bottom, + class: "analytics-cursor", + }); + svg.append(cursor); + function detail(index) { + const row = chartRows[index]; + cursor.setAttribute("x1", x(index)); + cursor.setAttribute("x2", x(index)); + $("#analytics-point-detail").textContent = `${row.date}: ${M.number( + row.value, + )} ${M.metrics[state.metric].toLowerCase()}${ + compare ? ` · ${row.previousDate}: ${M.number(row.previous)}` : "" + }`; + inspect.setAttribute( + "aria-valuetext", + $("#analytics-point-detail").textContent, + ); + } + inspect.oninput = () => detail(Number(inspect.value)); + function inspectPointer(event) { + const bounds = svg.getBoundingClientRect(); + const relative = ((event.clientX - bounds.left) / bounds.width) * width; + const index = Math.max( + 0, + Math.min( + chartRows.length - 1, + Math.round( + ((relative - left) / (right - left)) * (chartRows.length - 1), + ), + ), + ); + inspect.value = String(index); + detail(index); + } + svg.addEventListener("pointermove", inspectPointer); + svg.addEventListener("pointerdown", inspectPointer); + target.append(svg); + detail(chartRows.length - 1); + } + + function ranking(items, metric) { + const list = el("ol", null, "analytics-ranking"); + items.forEach((row) => { + const li = el("li"), + title = el("div", null, "analytics-ranking-title"); + title.append(el("span", row.label), el("strong", M.number(row.value))); + const track = el("div", null, "analytics-rank-track"); + track.setAttribute("aria-hidden", "true"); + const bar = el("span"); + bar.style.width = `${row.share}%`; + track.append(bar); + li.append( + title, + track, + el("small", `${M.share(row.share)} of ${metric}`), + ); + list.append(li); + }); + return list; + } + function status(panel) { + if (!panel) + return "This retained snapshot does not include this report. A successful new refresh is needed."; + if (panel.status === "withheld") + return "Google Analytics restricted this breakdown. No values have been inferred."; + if (!panel.total) + return `No measured ${panel.metric} were returned. This does not prove there were no visits.`; + return `${M.number(panel.total)} ${ + panel.metric + } returned for this breakdown. Shares use this total, not the headline user count.`; + } + function table(key) { + const panel = current().breakdowns[key], + filter = state.filters[key] || { + search: "", + sort: "value", + direction: "desc", + }; + const items = M.rows( + panel, + filter.search, + filter.sort, + filter.direction, + otherLabel(key), + ); + const target = $(`[data-table="${key}"]`), + body = target.querySelector("tbody"); + body.replaceChildren(); + items.forEach((row) => { + const tr = el("tr"), + label = el("th"); + label.scope = "row"; + if (key === "pages" && !row.other) { + const link = el("a", row.label); + link.href = row.label; + label.append(link); + } else label.textContent = row.label; + tr.append( + label, + el("td", M.number(row.value)), + el("td", M.share(row.share)), + ); + body.append(tr); + }); + if (!items.length) { + const tr = el("tr"), + cell = el( + "td", + panel?.status === "available" + ? "No matching categories." + : "No published rows.", + ); + cell.colSpan = 3; + tr.append(cell); + body.append(tr); + } + $(`[data-panel-status="${key}"]`).textContent = `${status(panel)}${ + panel?.status === "available" ? ` Showing ${items.length} rows.` : "" + }`; + $$(`[data-panel="${key}"]`).forEach((button) => { + const active = button.dataset.sort === filter.sort; + button.parentElement.setAttribute( + "aria-sort", + active + ? filter.direction === "asc" + ? "ascending" + : "descending" + : "none", + ); + button.textContent = `${ + button.dataset.sort === "label" + ? "Category" + : panel?.metric === "sessions" + ? "Sessions" + : "Pageviews" + } ${active ? (filter.direction === "asc" ? "↑" : "↓") : "↕"}`; + }); + $(`[data-ranking="${key}"]`).replaceChildren( + ranking(items.slice(0, 5), panel?.metric || "pageviews"), + ); + $(`[data-export="${key}"]`).disabled = panel?.status !== "available"; + $(`[data-search="${key}"]`).disabled = panel?.status !== "available"; + return items; + } + function overview() { + const grid = $("#analytics-overview-panels"); + grid.replaceChildren(); + Object.entries(M.dimensions).forEach(([key, title]) => { + const panel = current().breakdowns[key], + card = el("section", null, "analytics-card analytics-preview"); + const heading = el("div", null, "analytics-card-heading"), + link = el("a", "View report →"); + link.href = `#report-${key}`; + link.dataset.report = key; + heading.append(el("h3", title), link); + card.append(heading); + if (panel?.status === "available" && panel.total) + card.append( + ranking( + M.rows(panel, "", "value", "desc", otherLabel(key)).slice(0, 4), + panel.metric, + ), + ); + else card.append(el("p", status(panel), "analytics-empty")); + grid.append(card); + }); + } + function render() { + metrics(); + chart(); + Object.keys(M.dimensions).forEach(table); + overview(); + } + selector.addEventListener("change", () => { + if (!windows[selector.value]) return; + state.window = selector.value; + render(); + }); + $$("[data-metric]").forEach((button) => + button.addEventListener("click", () => { + state.metric = button.dataset.metric; + navigate("overview"); + history.replaceState(null, "", "#report-overview"); + metrics(); + chart(); + }), + ); + $("#analytics-compare").addEventListener("change", chart); + $("#analytics-table-toggle").addEventListener("click", () => { + state.table = !state.table; + $("#analytics-series-table").hidden = !state.table; + $("#analytics-table-toggle").setAttribute( + "aria-expanded", + String(state.table), + ); + $("#analytics-table-toggle").textContent = state.table + ? "Hide data table" + : "View data table"; + }); + $("#analytics-series-table").hidden = true; + $("#analytics-series-csv").addEventListener("click", () => { + const compare = $("#analytics-compare").checked && fullSeries; + download( + [ + ...metadataRows(`Daily ${M.metrics[state.metric]}`), + [ + "Date", + M.metrics[state.metric], + ...(compare ? ["Previous date", "Previous value"] : []), + ], + ...chartRows.map((row) => [ + row.date, + row.value ?? "Not reported", + ...(compare + ? [row.previousDate, row.previous ?? "Not reported"] + : []), + ]), + ], + "daily", + ); + }); + $$("[data-search]").forEach((input) => + input.addEventListener("input", () => { + const key = input.dataset.search; + state.filters[key] = { + ...(state.filters[key] || { sort: "value", direction: "desc" }), + search: input.value, + }; + table(key); + }), + ); + $$("[data-sort]").forEach((button) => + button.addEventListener("click", () => { + const key = button.dataset.panel, + old = state.filters[key] || { + search: "", + sort: "value", + direction: "desc", + }; + state.filters[key] = { + ...old, + sort: button.dataset.sort, + direction: + old.sort === button.dataset.sort && old.direction === "desc" + ? "asc" + : "desc", + }; + table(key); + }), + ); + $$("[data-export]").forEach((button) => + button.addEventListener("click", () => { + const key = button.dataset.export, + panel = current().breakdowns[key]; + if (panel?.status !== "available") return; + download( + [ + ...metadataRows(M.dimensions[key]), + ["Search", state.filters[key]?.search || ""], + ["Breakdown total", panel.total], + ["Category", panel.metric, "Share of full breakdown"], + ...table(key).map((row) => [ + row.label, + row.value, + M.share(row.share), + ]), + ], + key, + ); + }), + ); + render(); + const initial = location.hash.startsWith("#report-") + ? location.hash.slice(8) + : "overview"; + navigate( + initial === "overview" || Object.hasOwn(M.dimensions, initial) + ? initial + : "overview", + ); })();