diff --git a/docs/analytics.md b/docs/analytics.md index d840d1a0..b08c9b6d 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -15,17 +15,22 @@ recreate working resources or generate private keys. ## Design and deployment - `/analytics/` uses the existing MkDocs custom theme and its light/dark tokens. - The cards, CSS bar chart, and accessible table 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 updates - the three-day stale notice; existing site tracking is separate. + 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. - `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. + 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. - 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. @@ -37,15 +42,20 @@ recreate working resources or generate private keys. re-queries the entire rolling and historical windows to capture late processing and revisions. A timezone change or midnight crossing mid-export fails safely. -- Monthly rows absent from GA4 are omitted, not imputed as zeros. Explicit zero - rows remain zero. A successful unrestricted empty summary means zero measured - events for that scope. An empty reason, restriction, sampling, thresholding, - truncation, malformed response, timeout, or failed request instead aborts the - refresh. Counts are not a census of people or all actual visits. +- Daily/monthly rows absent from GA4 are omitted, not imputed as zeros. Charts + show gaps and tables say Not reported. Explicit zero rows remain zero. A + successful unrestricted empty summary means zero measured events for that + scope. An empty reason, sampling, truncation, malformed response, timeout, or + failed request instead aborts the refresh. Core-report thresholding or metric + restrictions also abort it. An explicit privacy/metric restriction on an + audience breakdown produces a `withheld` panel with null totals, not zeros; + other validated panels and headline statistics can still be published. Counts + are not a census of people or all actual visits. - `pages/analytics/schema.json` is the closed, versioned public contract. `additionalProperties: false` and fixed source fields prevent raw responses, property IDs, credentials, or visitor details leaking into the JSON. Further - validation checks calendar boundaries and unique, chronological months. + validation checks calendar boundaries, unique chronological dates/months, + adjacent comparison dates, ranking order and reconciled breakdown totals. `status: unavailable` has null dates, timezone and metrics, not fabricated zeros. @@ -58,6 +68,60 @@ 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. | +### 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. + +Each breakdown includes `status`, `metric`, `minimum_active_users`, `total`, +`other` and `rows` (`label`, `value`). The exporter uses per-category +**whole-period** `activeUsers` only to require at least ten active users before +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. + +Shares are display-only and use each panel's own returned additive `total`, +including `other`, not distinct users or the headline denominator. Rounded +shares may not sum to exactly 100%. A panel's `total` may differ from the +headline during processing or because of reporting semantics; neither is +rewritten to force agreement. The page also flags daily/headline discrepancies +and zero pageviews as reasons to check collection and scope, not proven faults. + +Rankings paginate in dimension order with 100 rows per request and a safety +limit of 1,000 rows per report. The exporter rejects duplicate rows, changing +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 +non-publication. A successful unrestricted empty panel is instead `available` +with zero totals. + +**Upgrading an existing deployment:** merge the dashboard changes, then start a +**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. + ### Durable snapshot storage The existing **`gh-pages` Git branch** stores the validated generated site, @@ -410,13 +474,18 @@ 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 - page cards/table must match the JSON. A successful export with all zeros - should prompt verification of collection/property/hostname configuration; it - does not prove zero actual visitors. + new report should have `schema_version: 2`; 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 + visitors. 5. Compare the summary with GA4 using the identical completed dates, timezone, Web platform and hostname filters. Check `activeUsers` as one period total, - not a sum. Compare available monthly `screenPageViews` separately. This is - **live GA4 validation**; mocked tests cannot replace it. + not a sum. Check the comparison period separately, and compare daily/monthly + `screenPageViews` and country/device/channel additive totals using their + respective dimensions. Apply the documented grouping before comparing visible + rankings; small named categories intentionally do not appear. This is **live + GA4 validation**; mocked tests cannot replace it. 6. Make an ordinary content deployment and confirm that the JSON, including `generated_at`, remains identical. Confirm the next scheduled/manual success updates it. If refreshes stop, the browser shows a stale notice after three @@ -500,11 +569,11 @@ 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 the download -link's keyboard focus 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 +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 exists, this covers the honest unavailable state. Inspect the PNGs rather than @@ -548,20 +617,20 @@ run. ## Troubleshooting -| Symptom | Checks / action | -| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `unrecognized arguments: --attribute-` / Bash “No such file” | A copy/paste inserted newlines inside a flag or value. Rerun the Bash argument-assembly block in Google setup step 3, then create or update the provider as appropriate. Do not split `--attribute-mapping`, claim names or `refs/heads/main`. | -| Resource already exists | Inspect and reuse it. Use `providers update-oidc` only to repair the dedicated provider's reviewed configuration; still complete the service-account binding and GA4 Viewer access. Do not delete the pool to retry setup. | -| Configuration missing / first report unavailable | Verify all four identifiers in `jobs.build.env`, the WIF service-account binding and GA4 property access; run the workflow on `main`. Do not fill the JSON with example numbers. | -| OIDC denied | Check numeric repository/owner IDs, exact repository case, ref, workflow path, issuer, full provider resource name, WIF service-account binding, Actions `id-token: write`, and IAM propagation. Do not weaken the branch condition to make a PR work. | -| `PermissionDenied` / 403 | Enable the Data API in the Cloud project; grant the service account Viewer on the specific GA4 property; check the readonly scope and property ID. Cloud IAM Viewer alone is insufficient. | -| `Unauthenticated` / 401 | Re-run to get a fresh token; do not copy tokens out of logs. Authentication should remain immediately before the export, not before dependency installation. | -| `InvalidArgument` / `ValueError` | Check property ID, comma-separated hostnames (no URL, wildcard, port, empty entry), the SDK version, and the report schema. The exporter also conservatively rejects GA4 thresholding/sampling/restrictions/empty reasons/truncation and timezone changes. Check these in the authenticated GA4 UI, not by logging raw responses. | -| Timeout / quota / service unavailable | Transient retries are bounded; keep the last report and retry later. Check Google API quotas and service health. Do not substitute zeros. | -| Snapshot restore or validation failure | Stop publication. Check repository access and `gh-pages:analytics/data.json`. Recover a known valid report from that branch's Git history through an authorized maintenance change. Never delete the snapshot simply to make CI green. | -| Pages deployment failed | Check Pages source is GitHub Actions, environment permits `main`, custom domain, and `pages: write`. A successfully archived report remains recoverable; rerun publication. | -| Schedule stopped / report stale | Check default branch, Actions enabled, inactivity auto-disable, queue delays and failed runs. Re-enable and dispatch manually. The old timestamp is intentionally retained. | -| Local Quarto failure | Repair the local Quarto installation; `mkdocs build` can separately verify committed Markdown and the analytics page, but does not replace the full blog pre-build check. | +| Symptom | Checks / action | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `unrecognized arguments: --attribute-` / Bash “No such file” | A copy/paste inserted newlines inside a flag or value. Rerun the Bash argument-assembly block in Google setup step 3, then create or update the provider as appropriate. Do not split `--attribute-mapping`, claim names or `refs/heads/main`. | +| Resource already exists | Inspect and reuse it. Use `providers update-oidc` only to repair the dedicated provider's reviewed configuration; still complete the service-account binding and GA4 Viewer access. Do not delete the pool to retry setup. | +| Configuration missing / first report unavailable | Verify all four identifiers in `jobs.build.env`, the WIF service-account binding and GA4 property access; run the workflow on `main`. Do not fill the JSON with example numbers. | +| OIDC denied | Check numeric repository/owner IDs, exact repository case, ref, workflow path, issuer, full provider resource name, WIF service-account binding, Actions `id-token: write`, and IAM propagation. Do not weaken the branch condition to make a PR work. | +| `PermissionDenied` / 403 | Enable the Data API in the Cloud project; grant the service account Viewer on the specific GA4 property; check the readonly scope and property ID. Cloud IAM Viewer alone is insufficient. | +| `Unauthenticated` / 401 | Re-run to get a fresh token; do not copy tokens out of logs. Authentication should remain immediately before the export, not before dependency installation. | +| `InvalidArgument` / `ValueError` | Check property ID, comma-separated hostnames (no URL, wildcard, port, empty entry), the SDK version, and the report schema. Core restrictions, sampling, empty reasons, truncation and timezone changes abort export; explicit audience restrictions produce withheld panels. Check these in the authenticated GA4 UI, not by logging raw responses. | +| Timeout / quota / service unavailable | Transient retries are bounded; keep the last report and retry later. Check Google API quotas and service health. Do not substitute zeros. | +| Snapshot restore or validation failure | Stop publication. Check repository access and `gh-pages:analytics/data.json`. Recover a known valid report from that branch's Git history through an authorized maintenance change. Never delete the snapshot simply to make CI green. | +| Pages deployment failed | Check Pages source is GitHub Actions, environment permits `main`, custom domain, and `pages: write`. A successfully archived report remains recoverable; rerun publication. | +| Schedule stopped / report stale | Check default branch, Actions enabled, inactivity auto-disable, queue delays and failed runs. Re-enable and dispatch manually. The old timestamp is intentionally retained. | +| Local Quarto failure | Repair the local Quarto installation; `mkdocs build` can separately verify committed Markdown and the analytics page, but does not replace the full blog pre-build check. | The exporter logs an exception **class**, never a potentially sensitive raw error message. Avoid `set -x`, SDK debug logging, or artifact uploads of the @@ -570,6 +639,42 @@ responses. Only `build/` is published, after the aggregate schema and credential audit pass. The audit is defense in depth, not permission to place secrets in content. +### Figures look unexpected + +Do not replace surprising numbers with the previously observed 1,699 or adjust +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. +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 + installation before adding anything; avoid duplicate tracking. This is a + diagnostic lead, not proof of why a particular report looks unusual. +3. **Dates and scope:** match the exact inclusive dates and property timezone, + `platform = web`, and only `opensciencelabs.org`. An unfiltered property UI + can include other sites; never broaden the allowlist just to raise totals. + The rolling window, previous window and completed months differ. +4. **Definitions:** compare Views with `screenPageViews`, Active users (not + Total users) with `activeUsers`, and Sessions with `sessions`. Do not sum + distinct users across dates, countries or devices. Channels use session + default channel group, not first-user channel or source/medium. +5. **Availability:** inspect GA4's data-quality indicators, consent/tag + coverage, filters and processing delays. `withheld` panels are explicitly + restricted, not empty. `Other / unknown` includes intentional grouping and + cannot be interpreted as a single country. Daily gaps do not prove zero + activity. +6. **Recheck after processing:** dispatch again later to re-query all windows. + Do not edit snapshot values or timestamps, disable consent, weaken Google + restrictions or expose raw API responses to force a plausible dashboard. + +For review, share only the already-public aggregate JSON or a redacted GA4 +screenshot with matching dates/filters. Never share access tokens or visitor +details. Offline fixtures and successful deployment logs cannot establish why +the production figures differ; reconcile the actual reports separately. + ### Local Quarto runtime lookup failures If the Conda Quarto launcher looks for a missing bundled `deno` or `pandoc`, diff --git a/pages/analytics/index.md b/pages/analytics/index.md index 76c3839e..5aee7431 100644 --- a/pages/analytics/index.md +++ b/pages/analytics/index.md @@ -20,43 +20,80 @@ 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 once over the 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 30-day 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. Monthly history covers up to 12 -completed calendar months; the current month is excluded. Only months returned -by GA4 are shown. Missing months are not invented as zero: they may predate -collection or have no available rows. Returned zero counts mean no measured -traffic for that query, not necessarily no actual visits. +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. + +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 +mean no measured traffic for that query, not necessarily no actual visits. The hostname scope above is an exact allowlist, combined with a Web-only filter. Other hostnames, including previews and localhost, and app traffic are excluded. This is website traffic, not OSL's combined audience across social media, community platforms, or other websites. +### Audience breakdowns + +- **Countries** show pageviews by GA4's `country` dimension. This is approximate + activity location, not nationality or residence. VPNs, proxies and location + determination can affect it. +- **Devices** show pageviews by `deviceCategory` (such as desktop or mobile), + not a count of distinct devices or people. +- **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 +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. + +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 +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. + ### Limitations and freshness Measured traffic can exclude visits affected by **consent choices, blockers**, disabled JavaScript, or collection errors. Identity settings, estimation, and cross-device behavior can affect user counts. Recent figures may change during -GA4 processing. Every refresh re-queries both the rolling window and the full -historical window, so earlier figures can be revised too. +GA4 processing. Every refresh re-queries the current and comparison windows, +daily and monthly histories, and all breakdowns, so earlier figures can be +revised too. A zero report warrants checking collection and scope before +concluding that nobody visited the site. Refreshes are scheduled daily at **06:23 UTC**, but GitHub Actions scheduling may be delayed. If a refresh fails, we retain the previous successful report and its original timestamp. A report more than three days old is marked stale. If -GA4 signals thresholding, sampling, truncation, or unavailable data, we do not -replace the previous report with incomplete results or fabricated zeros. +GA4 explicitly signals a privacy threshold or metric access restriction for an +audience breakdown, that panel is marked **Not published**, not zero. A +restriction on core statistics, sampling, truncation, unavailable data, or a +failed request instead retains the entire previous report. We never publish +partially fetched rankings or label a failed attempt as a successful refresh. Metric definitions follow Google's [GA4 Data API schema](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema). See also Google's [data freshness guidance](https://support.google.com/analytics/answer/12233314). -The endpoint follows our [version 1 JSON schema](schema.json). +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. ## Support open science diff --git a/pages/analytics/schema.json b/pages/analytics/schema.json index 4be9673a..05d971d2 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", - "description": "Closed aggregate-only contract. Nulls mean unavailable, never zero. Dates are inclusive in the property timezone. Missing historical months are omitted, not imputed. generated_at is the last successful export in UTC.", + "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.", "type": "object", "additionalProperties": false, "required": [ @@ -20,9 +20,15 @@ "monthly_history" ], "properties": { - "schema_version": { "const": 1 }, - "data_kind": { "enum": ["production", "fixture"] }, - "status": { "enum": ["available", "unavailable"] }, + "schema_version": { + "enum": [1, 2] + }, + "data_kind": { + "enum": ["production", "fixture"] + }, + "status": { + "enum": ["available", "unavailable"] + }, "source": { "const": { "publisher": "Open Science Labs", @@ -30,36 +36,53 @@ "api": "Google Analytics Data API v1beta" } }, - "site": { "const": "https://opensciencelabs.org" }, + "site": { + "const": "https://opensciencelabs.org" + }, "hostnames": { "type": "array", "uniqueItems": true, - "items": { "type": "string", "format": "hostname" } + "items": { + "type": "string", + "format": "hostname" + } + }, + "timezone": { + "type": ["string", "null"], + "minLength": 1 }, - "timezone": { "type": ["string", "null"], "minLength": 1 }, "generated_at": { "type": ["string", "null"], "format": "date-time", "pattern": "Z$" }, "reporting_period": { - "oneOf": [{ "$ref": "#/$defs/period" }, { "type": "null" }] + "oneOf": [ + { + "$ref": "#/$defs/period" + }, + { + "type": "null" + } + ] }, "history_period": { - "oneOf": [{ "$ref": "#/$defs/period" }, { "type": "null" }] + "oneOf": [ + { + "$ref": "#/$defs/period" + }, + { + "type": "null" + } + ] }, "summary": { "oneOf": [ - { "type": "null" }, { - "type": "object", - "additionalProperties": false, - "required": ["pageviews", "active_users", "sessions"], - "properties": { - "pageviews": { "$ref": "#/$defs/count" }, - "active_users": { "$ref": "#/$defs/count" }, - "sessions": { "$ref": "#/$defs/count" } - } + "type": "null" + }, + { + "$ref": "#/$defs/summary" } ] }, @@ -75,49 +98,367 @@ "type": "string", "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$" }, - "start": { "type": "string", "format": "date" }, - "end": { "type": "string", "format": "date" }, - "pageviews": { "$ref": "#/$defs/count" } + "start": { + "type": "string", + "format": "date" + }, + "end": { + "type": "string", + "format": "date" + }, + "pageviews": { + "$ref": "#/$defs/count" + } } } + }, + "comparison": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": ["reporting_period", "summary"], + "properties": { + "reporting_period": { + "$ref": "#/$defs/period" + }, + "summary": { + "$ref": "#/$defs/summary" + } + } + } + ] + }, + "daily_history": { + "type": "array", + "maxItems": 30, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["date", "pageviews"], + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "pageviews": { + "$ref": "#/$defs/count" + } + } + } + }, + "breakdowns": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": ["countries", "devices", "channels"], + "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" + } + } + } + ] + } + } + } + ] } }, "allOf": [ { - "if": { "properties": { "status": { "const": "available" } } }, + "if": { + "properties": { + "status": { + "const": "available" + } + } + }, "then": { "properties": { - "hostnames": { "minItems": 1 }, - "timezone": { "type": "string" }, - "generated_at": { "type": "string" }, - "reporting_period": { "$ref": "#/$defs/period" }, - "history_period": { "$ref": "#/$defs/period" }, - "summary": { "type": "object" } + "hostnames": { + "minItems": 1 + }, + "timezone": { + "type": "string" + }, + "generated_at": { + "type": "string" + }, + "reporting_period": { + "$ref": "#/$defs/period" + }, + "history_period": { + "$ref": "#/$defs/period" + }, + "summary": { + "type": "object" + } } }, "else": { "properties": { - "hostnames": { "maxItems": 0 }, - "timezone": { "type": "null" }, - "generated_at": { "type": "null" }, - "reporting_period": { "type": "null" }, - "history_period": { "type": "null" }, - "summary": { "type": "null" }, - "monthly_history": { "maxItems": 0 } + "hostnames": { + "maxItems": 0 + }, + "timezone": { + "type": "null" + }, + "generated_at": { + "type": "null" + }, + "reporting_period": { + "type": "null" + }, + "history_period": { + "type": "null" + }, + "summary": { + "type": "null" + }, + "monthly_history": { + "maxItems": 0 + } } } + }, + { + "if": { + "properties": { + "schema_version": { + "const": 1 + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": ["comparison"] + }, + { + "required": ["daily_history"] + }, + { + "required": ["breakdowns"] + } + ] + } + }, + "else": { + "required": ["comparison", "daily_history", "breakdowns"], + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "available" + } + } + }, + "then": { + "properties": { + "comparison": { + "type": "object" + }, + "breakdowns": { + "type": "object" + } + } + }, + "else": { + "properties": { + "comparison": { + "type": "null" + }, + "daily_history": { + "maxItems": 0 + }, + "breakdowns": { + "type": "null" + } + } + } + } + ] + } } ], "$defs": { - "count": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "period": { "type": "object", "additionalProperties": false, "required": ["start", "end"], "properties": { - "start": { "type": "string", "format": "date" }, - "end": { "type": "string", "format": "date" } + "start": { + "type": "string", + "format": "date" + }, + "end": { + "type": "string", + "format": "date" + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["pageviews", "active_users", "sessions"], + "properties": { + "pageviews": { + "$ref": "#/$defs/count" + }, + "active_users": { + "$ref": "#/$defs/count" + }, + "sessions": { + "$ref": "#/$defs/count" + } } + }, + "breakdown": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "metric", + "minimum_active_users", + "total", + "other", + "rows" + ], + "properties": { + "status": { + "enum": ["available", "withheld"] + }, + "metric": { + "enum": ["pageviews", "sessions"] + }, + "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", + "minLength": 1, + "maxLength": 80, + "pattern": "^[^\\u0000-\\u001f<>]+$" + }, + "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" + } + } + } + } + ] } } } diff --git a/scripts/analytics/export.py b/scripts/analytics/export.py index 1cedf805..355e5c22 100644 --- a/scripts/analytics/export.py +++ b/scripts/analytics/export.py @@ -11,11 +11,14 @@ from pathlib import Path from scripts.analytics.report import ( + MAX_GROUP_ROWS, METRICS, + MIN_GROUP_USERS, SNAPSHOT, hostnames, month_period, periods, + previous_period, unavailable, validate, write_snapshot, @@ -23,6 +26,22 @@ SCOPE = "https://www.googleapis.com/auth/analytics.readonly" REPOSITORY = "OpenScienceLabs/opensciencelabs.github.io" +PAGE_SIZE = 100 +MAX_QUERY_ROWS = 1000 +BREAKDOWNS = { + "countries": ("country", "screenPageViews", "pageviews"), + "devices": ("deviceCategory", "screenPageViews", "pageviews"), + "channels": ("sessionDefaultChannelGroup", "sessions", "sessions"), +} + + +class RestrictedReport(ValueError): + """GA4 supplied an explicit privacy or metric restriction, not zeros.""" + + def __init__(self, zone): + """Retain only the timezone, never a raw API response.""" + super().__init__("GA4 withheld this report") + self.zone = zone class GoogleClient: @@ -93,8 +112,8 @@ def dimension_filter(hosts: list[str]) -> dict: } -def query(client, settings, period, metrics, dimensions=()): - """Use bounded retries and never request visitor-level dimensions.""" +def query_page(client, settings, period, metrics, dimensions, offset): + """Validate one bounded page; retain only counts and requested labels.""" request = dict( property=f"properties/{settings.property_id}", date_ranges=[ @@ -104,21 +123,33 @@ def query(client, settings, period, metrics, dimensions=()): dimensions=[{"name": name} for name in dimensions], dimension_filter=dimension_filter(settings.hosts), keep_empty_rows=True, - limit=100, + limit=PAGE_SIZE, + offset=offset, + order_bys=[ + {"dimension": {"dimension_name": name}} for name in dimensions + ], ) response = client.run_report(request=request) metadata = response.metadata + if ( + metadata.subject_to_thresholding + or metadata.schema_restriction_response.active_metric_restrictions + ): + raise RestrictedReport(metadata.time_zone) if ( metadata.empty_reason - or metadata.subject_to_thresholding or metadata.data_loss_from_other_row or metadata.sampling_metadatas or getattr(metadata, "data_truncation_reasons", ()) - or metadata.schema_restriction_response.active_metric_restrictions ): raise ValueError("GA4 report is unavailable, restricted or incomplete") - if response.row_count != len(response.rows): - raise ValueError("Incomplete GA4 rows") + if ( + not 0 <= response.row_count <= MAX_QUERY_ROWS + or len(response.rows) > PAGE_SIZE + or offset + len(response.rows) > response.row_count + or (not response.rows and response.row_count != 0) + ): + raise ValueError("Incomplete or excessive GA4 rows") if [header.name for header in response.dimension_headers] != list( dimensions ): @@ -141,7 +172,76 @@ def query(client, settings, period, metrics, dimensions=()): dict(zip(names, map(int, values), strict=True)), ) ) - return metadata.time_zone, result + return metadata.time_zone, response.row_count, result + + +def query(client, settings, period, metrics, dimensions=()): + """Paginate with stable ordering; reject changing or duplicated rows.""" + zone, total, result = query_page( + client, settings, period, metrics, dimensions, 0 + ) + while len(result) < total: + next_zone, next_total, rows = query_page( + client, settings, period, metrics, dimensions, len(result) + ) + if (next_zone, next_total) != (zone, total): + raise ValueError("GA4 report changed during pagination") + result.extend(rows) + keys = [tuple(dims) for dims, _ in result] + if len(keys) != len(set(keys)): + raise ValueError("Duplicate GA4 rows") + return zone, result + + +def summary_counts(rows): + """Map one period-level report; never sum distinct-user counts.""" + if len(rows) > 1: + raise ValueError("Expected one period-level summary") + counts = rows[0][1] if rows else dict.fromkeys(METRICS, 0) + return {public: counts[api] for api, public in METRICS.items()} + + +def breakdown(client, settings, period, dimension, api_metric, public_metric): + """Publish coarse rankings, grouping small and unclassified categories.""" + panel = { + "status": "withheld", + "metric": public_metric, + "minimum_active_users": MIN_GROUP_USERS, + "rows": [], + "total": None, + "other": None, + } + try: + zone, rows = query( + client, settings, period, [api_metric, "activeUsers"], [dimension] + ) + except RestrictedReport as error: + # An explicit GA4 restriction is an honest unavailable panel. Network, + # permission, sampling, pagination and validation errors still abort + # the entire refresh, leaving the previous snapshot untouched. + return error.zone, panel + total = sum(counts[api_metric] for _, counts in rows) + visible = [] + for dims, counts in rows: + label = dims[0] + if counts["activeUsers"] < MIN_GROUP_USERS or label.lower() in { + "", + "(not set)", + "(other)", + "unknown", + }: + continue + if 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"])) + panel.update( + status="available", + rows=visible[:MAX_GROUP_ROWS], + total=total, + other=total - sum(row["value"] for row in visible[:MAX_GROUP_ROWS]), + ) + return zone, panel def collect(client, settings: Settings, now: datetime | None = None) -> dict: @@ -164,11 +264,8 @@ def collect(client, settings: Settings, now: datetime | None = None) -> dict: ) if zone != summary_zone or zone != history_zone: raise ValueError("Property timezone changed during export; retry") - if len(summary_rows) > 1: - raise ValueError("Expected one period-level summary, not daily users") # A successful empty report without an emptyReason/restriction means no # measured events for this scope, NOT a failed or unconfigured request. - counts = summary_rows[0][1] if summary_rows else dict.fromkeys(METRICS, 0) report = unavailable() report.update( status="available", @@ -176,7 +273,7 @@ def collect(client, settings: Settings, now: datetime | None = None) -> dict: timezone=zone, reporting_period=rolling, history_period=history, - summary={public: counts[api] for api, public in METRICS.items()}, + summary=summary_counts(summary_rows), ) for dimensions, values in history_rows: value = dimensions[0] @@ -193,6 +290,36 @@ 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) + ) + report["comparison"] = { + "reporting_period": comparison, + "summary": summary_counts(comparison_rows), + } + 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/hook.py b/scripts/analytics/hook.py index 7d66fe49..e99054e3 100644 --- a/scripts/analytics/hook.py +++ b/scripts/analytics/hook.py @@ -8,6 +8,7 @@ # MkDocs loads hooks by filename, not as modules in the project package. sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from scripts.analytics.presentation import dashboard from scripts.analytics.report import ( SNAPSHOT, is_stale, @@ -28,6 +29,7 @@ def on_config(config): raise ValueError("Preview input must be explicitly labeled fixture") config.extra["analytics_report"] = report config.extra["analytics_stale"] = is_stale(report) + config.extra["analytics_view"] = dashboard(report) return config diff --git a/scripts/analytics/presentation.py b/scripts/analytics/presentation.py new file mode 100644 index 00000000..bf63a3b1 --- /dev/null +++ b/scripts/analytics/presentation.py @@ -0,0 +1,179 @@ +"""Pure, credential-free dashboard formatting; never alters published data.""" + +import math + +from datetime import date, timedelta + +from scripts.analytics.report import month_period + +PERCENT_PRECISION = 0.1 + + +def change(current, previous): + """Avoid infinite growth claims when the previous period is zero.""" + if previous is None: + return {"text": "Comparison not available yet", "direction": "neutral"} + if previous == 0: + return { + "text": "No percentage baseline · previously 0", + "direction": "neutral", + } + percent = (current - previous) / previous * 100 + direction = "up" if percent > 0 else "down" if percent < 0 else "neutral" + if abs(percent) < PERCENT_PRECISION / 2: + label = "Less than 0.1% change" if percent else "No change" + else: + label = f"{abs(percent):,.1f}% {'more' if percent > 0 else 'less'}" + return {"text": label, "direction": direction} + + +def chart(rows, period, *, monthly=False): + """Draw honest zero-based SVG charts with gaps for unreported dates.""" + key = "month" if monthly else "date" + lookup = {row[key]: row["pageviews"] for row in rows} + start = date.fromisoformat(period["start"]) + end = date.fromisoformat(period["end"]) + dates = [] + cursor = start + while cursor <= end: + dates.append( + cursor.strftime("%Y-%m") if monthly else cursor.isoformat() + ) + cursor = ( + (cursor.replace(day=28) + timedelta(days=4)).replace(day=1) + if monthly + else cursor + timedelta(days=1) + ) + maximum = max(lookup.values(), default=0) + magnitude = 10 ** math.floor(math.log10(max(maximum, 1))) + ceiling = max(4, math.ceil(maximum / magnitude) * magnitude) + ceiling = math.ceil(ceiling / 4) * 4 + # 720x240 viewBox with margins for unambiguous axes. Never smooth curves. + width, height, left, bottom = 600, 160, 100, 190 + slots, segments, segment = [], [], [] + for index, day in enumerate(dates): + value = lookup.get(day) + x = left + (index + 0.5) * width / len(dates) + y = None if value is None else bottom - value / ceiling * height + point = {"date": day, "value": value, "x": round(x, 2), "y": y} + if monthly: + point.update(month_period(day)) + point["bar_height"] = None if y is None else bottom - y + slots.append(point) + if y is None: + if segment: + segments.append(" ".join(segment)) + segment = [] + else: + segment.append(f"{x:.2f},{y:.2f}") + if segment: + segments.append(" ".join(segment)) + return { + "slots": slots, + "segments": segments, + "bar_width": min(28, width / len(dates) * 0.6), + "ticks": [ + {"y": bottom - i * height / 4, "label": f"{ceiling * i / 4:,.0f}"} + for i in range(5) + ], + "first": dates[0], + "last": dates[-1], + "has_data": bool(rows), + "period": period, + } + + +def dashboard(report): + """Prepare derived display values separately from the JSON contract.""" + result = {"metrics": [], "notices": [], "panels": []} + if report["status"] != "available": + return result + comparison = report.get("comparison") + for key, label, definition in [ + ( + "pageviews", + "Pageviews", + "Measured page loads, including repeat views", + ), + ( + "active_users", + "Active users", + "Distinct active users over all 30 days", + ), + ( + "sessions", + "Sessions", + "Visits that began during this reporting period", + ), + ]: + previous = comparison["summary"][key] if comparison else None + result["metrics"].append( + { + "key": key, + "label": label, + "definition": definition, + "value": report["summary"][key], + "previous": previous, + **change(report["summary"][key], previous), + } + ) + result["monthly"] = chart( + report["monthly_history"], report["history_period"], monthly=True + ) + result["daily"] = chart( + report.get("daily_history", []), report["reporting_period"] + ) + if report["schema_version"] == 1: + result["notices"].append( + "This retained report predates the expanded dashboard. Daily " + "trends, comparisons and audience panels await a successful " + "refresh." + ) + if report["summary"]["pageviews"] == 0: + result["notices"].append( + "GA4 returned no measured pageviews for this scope. This does not " + "prove there were no visits; collection and hostname settings " + "should be checked before interpreting the result." + ) + elif ( + report["schema_version"] != 1 + and sum(row["pageviews"] for row in report["daily_history"]) + != report["summary"]["pageviews"] + ): + result["notices"].append( + "Daily and headline pageview totals differ in this export. " + "Processing or reporting differences may be responsible; no " + "figures have been adjusted to force a match." + ) + for key, title, description in [ + ("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"), + ]: + panel = (report.get("breakdowns") or {}).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"]} + ] + for row in rows: + share = ( + row["value"] / panel["total"] * 100 + if panel["total"] + else 0 + ) + item["display_rows"].append( + { + **row, + "share": share, + "share_label": ( + "<0.1%" + if 0 < share < PERCENT_PRECISION + else f"{share:.1f}%" + ), + } + ) + result["panels"].append(item) + return result diff --git a/scripts/analytics/report.py b/scripts/analytics/report.py index 4fa5b109..7a44e7f7 100644 --- a/scripts/analytics/report.py +++ b/scripts/analytics/report.py @@ -28,6 +28,8 @@ WINDOW_DAYS = 30 STALE_DAYS = 3 MAX_HOSTNAME_LENGTH = 253 +MIN_GROUP_USERS = 10 +MAX_GROUP_ROWS = 10 def hostnames(value: str) -> list[str]: @@ -72,10 +74,19 @@ def month_period(month: str) -> dict: } +def previous_period(period: dict) -> dict: + """Use the preceding 30 calendar days, without elapsed-time arithmetic.""" + start = date.fromisoformat(period["start"]) + return { + "start": (start - timedelta(days=WINDOW_DAYS)).isoformat(), + "end": (start - timedelta(days=1)).isoformat(), + } + + def unavailable() -> dict: """Represent an unconfigured first deployment without invented values.""" return { - "schema_version": 1, + "schema_version": 2, "data_kind": "production", "status": "unavailable", "source": dict(SOURCE), @@ -87,6 +98,9 @@ def unavailable() -> dict: "history_period": None, "summary": None, "monthly_history": [], + "comparison": None, + "daily_history": [], + "breakdowns": None, } @@ -125,6 +139,35 @@ def validate(report: dict, *, allow_fixture: bool = False) -> None: months.append(item["month"]) if months != sorted(set(months)): raise ValueError("Monthly history must be unique and chronological") + if report["schema_version"] == 1: + # Preserve the original document and successful timestamp, not an + # invented v2 export. The renderer explains missing dashboard panels. + return + if report["comparison"]["reporting_period"] != previous_period( + expected_summary + ): + raise ValueError("Incorrect comparison period") + dates = [row["date"] for row in report["daily_history"]] + if dates != sorted(set(dates)) or any( + not expected_summary["start"] <= day <= expected_summary["end"] + for day in dates + ): + raise ValueError("Daily history must be unique and within the period") + for panel in report["breakdowns"].values(): + if panel["status"] != "available": + continue + labels = [row["label"] for row in panel["rows"]] + if len(labels) != len(set(labels)): + raise ValueError("Duplicate breakdown labels") + if panel["rows"] != sorted( + panel["rows"], key=lambda row: (-row["value"], row["label"]) + ): + raise ValueError("Breakdown rows must be ranked deterministically") + if ( + sum(row["value"] for row in panel["rows"]) + panel["other"] + != (panel["total"]) + ): + raise ValueError("Breakdown counts do not reconcile") def read_snapshot(path: Path, *, allow_fixture: bool = False) -> dict: diff --git a/tests/analytics-js.test.cjs b/tests/analytics-js.test.cjs index 914e98e8..ad6cfdf1 100644 --- a/tests/analytics-js.test.cjs +++ b/tests/analytics-js.test.cjs @@ -21,6 +21,7 @@ function run(age, available = true) { }, }, document: { + querySelector: () => null, getElementById: (id) => !available ? null @@ -60,3 +61,43 @@ test("already open and background tabs age without another deployment", () => { 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); + } +}); diff --git a/tests/browser_analytics.py b/tests/browser_analytics.py index e0f5c78f..e821e377 100644 --- a/tests/browser_analytics.py +++ b/tests/browser_analytics.py @@ -9,6 +9,30 @@ REQUIRED_MOBILE_WIDTH = 390 +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}"]') + 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") + + def check(url: str, output: Path) -> None: """Inspect responsive color modes without sending synthetic GA traffic.""" from playwright.sync_api import sync_playwright @@ -54,6 +78,7 @@ def check(url: str, output: Path) -> None: "el => el === document.activeElement" ): raise AssertionError("Download link is not focusable") + check_chart_controls(page, output, width, mode) page.screenshot( path=str(output / f"analytics-{width}-{mode}.png"), full_page=True, @@ -66,6 +91,12 @@ 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(): + 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 data table requires JavaScript") context.close() browser.close() print(f"Browser smoke checks passed; inspect screenshots in {output}") diff --git a/tests/fixtures/analytics-v1.json b/tests/fixtures/analytics-v1.json new file mode 100644 index 00000000..4096037e --- /dev/null +++ b/tests/fixtures/analytics-v1.json @@ -0,0 +1,101 @@ +{ + "schema_version": 1, + "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": 1234, + "active_users": 345, + "sessions": 678 + }, + "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 + } + ] +} diff --git a/tests/fixtures/analytics.json b/tests/fixtures/analytics.json index 4096037e..6ac480fd 100644 --- a/tests/fixtures/analytics.json +++ b/tests/fixtures/analytics.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "data_kind": "fixture", "status": "available", "source": { @@ -97,5 +97,215 @@ "end": "2026-08-31", "pageviews": 1100 } - ] + ], + "comparison": { + "reporting_period": { + "start": "2026-07-18", + "end": "2026-08-16" + }, + "summary": { + "pageviews": 980, + "active_users": 300, + "sessions": 590 + } + }, + "daily_history": [ + { + "date": "2026-08-17", + "pageviews": 30 + }, + { + "date": "2026-08-18", + "pageviews": 36 + }, + { + "date": "2026-08-19", + "pageviews": 41 + }, + { + "date": "2026-08-20", + "pageviews": 26 + }, + { + "date": "2026-08-21", + "pageviews": 38 + }, + { + "date": "2026-08-22", + "pageviews": 28 + }, + { + "date": "2026-08-23", + "pageviews": 33 + }, + { + "date": "2026-08-24", + "pageviews": 43 + }, + { + "date": "2026-08-25", + "pageviews": 45 + }, + { + "date": "2026-08-26", + "pageviews": 36 + }, + { + "date": "2026-08-27", + "pageviews": 24 + }, + { + "date": "2026-08-28", + "pageviews": 27 + }, + { + "date": "2026-08-29", + "pageviews": 31 + }, + { + "date": "2026-08-30", + "pageviews": 29 + }, + { + "date": "2026-08-31", + "pageviews": 41 + }, + { + "date": "2026-09-01", + "pageviews": 51 + }, + { + "date": "2026-09-02", + "pageviews": 42 + }, + { + "date": "2026-09-03", + "pageviews": 39 + }, + { + "date": "2026-09-04", + "pageviews": 55 + }, + { + "date": "2026-09-05", + "pageviews": 45 + }, + { + "date": "2026-09-06", + "pageviews": 48 + }, + { + "date": "2026-09-07", + "pageviews": 37 + }, + { + "date": "2026-09-08", + "pageviews": 52 + }, + { + "date": "2026-09-09", + "pageviews": 54 + }, + { + "date": "2026-09-10", + "pageviews": 32 + }, + { + "date": "2026-09-11", + "pageviews": 41 + }, + { + "date": "2026-09-12", + "pageviews": 58 + }, + { + "date": "2026-09-13", + "pageviews": 51 + }, + { + "date": "2026-09-14", + "pageviews": 46 + }, + { + "date": "2026-09-15", + "pageviews": 75 + } + ], + "breakdowns": { + "countries": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 1234, + "other": 174, + "rows": [ + { + "label": "Brazil", + "value": 410 + }, + { + "label": "United States", + "value": 280 + }, + { + "label": "India", + "value": 175 + }, + { + "label": "Germany", + "value": 110 + }, + { + "label": "United Kingdom", + "value": 85 + } + ] + }, + "devices": { + "status": "available", + "metric": "pageviews", + "minimum_active_users": 10, + "total": 1234, + "other": 14, + "rows": [ + { + "label": "desktop", + "value": 740 + }, + { + "label": "mobile", + "value": 420 + }, + { + "label": "tablet", + "value": 60 + } + ] + }, + "channels": { + "status": "available", + "metric": "sessions", + "minimum_active_users": 10, + "total": 678, + "other": 28, + "rows": [ + { + "label": "Organic Search", + "value": 310 + }, + { + "label": "Direct", + "value": 180 + }, + { + "label": "Referral", + "value": 95 + }, + { + "label": "Organic Social", + "value": 65 + } + ] + } + } } diff --git a/tests/test_analytics.py b/tests/test_analytics.py index bf34df65..cc8d3c22 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -70,6 +70,30 @@ def __init__(self, results=None): [(["202608"], [100]), (["202607"], [0])], ["yearMonth"], ), + response( + ["sessions", "screenPageViews", "activeUsers"], + [([], [55, 90, 25])], + ), + response( + ["screenPageViews"], + [(["20260914"], [70]), (["20260915"], [50])], + ["date"], + ), + response( + ["screenPageViews", "activeUsers"], + [(["Brazil"], [90, 20]), (["France"], [30, 5])], + ["country"], + ), + response( + ["screenPageViews", "activeUsers"], + [(["desktop"], [100, 20]), (["mobile"], [20, 10])], + ["deviceCategory"], + ), + response( + ["sessions", "activeUsers"], + [(["Direct"], [40, 18]), (["Organic Search"], [30, 15])], + ["sessionDefaultChannelGroup"], + ), ] ) @@ -343,7 +367,7 @@ def test_extra_fields_and_invalid_values_rejected(self): lambda data: data["summary"].update(visitor_id="SYNTHETIC"), lambda data: data["summary"].update(pageviews=-1), lambda data: data["summary"].update(pageviews=True), - lambda data: data.update(schema_version=2), + lambda data: data.update(schema_version=3), lambda data: data.update(generated_at="not-a-date"), lambda data: data.update(timezone="Invalid/Zone"), lambda data: data["reporting_period"].update(end="2026-09-16"), diff --git a/tests/test_analytics_dashboard.py b/tests/test_analytics_dashboard.py new file mode 100644 index 00000000..485537c3 --- /dev/null +++ b/tests/test_analytics_dashboard.py @@ -0,0 +1,367 @@ +"""Expanded dashboard contracts using explicitly synthetic offline data.""" + +import copy +import json +import tempfile +import unittest + +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace as Obj +from unittest.mock import patch + +import test_analytics_presentation as presentation_tests + +from jsonschema import ValidationError +from test_analytics import NOW, SETTINGS, FakeClient, response + +from scripts.analytics import export, hook, presentation, report + +FIXTURE = Path("tests/fixtures/analytics.json") +LEGACY = Path("tests/fixtures/analytics-v1.json") + + +class ExpandedExportTests(unittest.TestCase): + """Whole-period users, safe pagination and honest partial availability.""" + + def test_comparison_has_independent_period_users(self): + """No daily or category user counts construct either headline.""" + client = FakeClient() + result = export.collect(client, SETTINGS, NOW) + self.assertEqual( + result["comparison"]["summary"], + {"pageviews": 90, "active_users": 25, "sessions": 55}, + ) + self.assertEqual(result["summary"]["active_users"], 31) + self.assertEqual(client.requests[3]["dimensions"], []) + self.assertEqual( + client.requests[3]["date_ranges"], + [{"start_date": "2026-07-18", "end_date": "2026-08-16"}], + ) + for request in client.requests[4:]: + self.assertEqual( + request["date_ranges"], client.requests[1]["date_ranges"] + ) + self.assertEqual( + client.requests[7]["metrics"], + [{"name": "sessions"}, {"name": "activeUsers"}], + ) + + def test_preceding_window_calendar_boundaries(self): + """Comparison periods are adjacent across leap days and local years.""" + for now, zone, expected in [ + ( + datetime(2024, 3, 31, 12, tzinfo=timezone.utc), + "UTC", + {"start": "2024-01-31", "end": "2024-02-29"}, + ), + ( + datetime(2026, 1, 1, 0, 30, tzinfo=timezone.utc), + "America/Los_Angeles", + {"start": "2025-11-01", "end": "2025-11-30"}, + ), + ]: + with self.subTest(zone=zone): + rolling, _ = report.periods(now, zone) + self.assertEqual(report.previous_period(rolling), expected) + + def test_small_unknown_and_remaining_categories_are_grouped(self): + """Apply the active-user threshold, not a pageview threshold.""" + rows = [([f"Country {i:02}"], [100 + i, 10]) for i in range(12)] + rows.extend( + [(["Small country"], [500, 9]), (["(not set)"], [200, 100])] + ) + client = FakeClient() + client.results[5] = response( + ["screenPageViews", "activeUsers"], rows, ["country"] + ) + panel = export.collect(client, SETTINGS, NOW)["breakdowns"][ + "countries" + ] + self.assertEqual(len(panel["rows"]), report.MAX_GROUP_ROWS) + self.assertEqual( + panel["rows"][0], {"label": "Country 11", "value": 111} + ) + self.assertEqual(panel["other"], 901) + self.assertEqual(panel["total"], 1966) + self.assertNotIn("Small country", json.dumps(panel)) + self.assertNotIn("(not set)", json.dumps(panel)) + self.assertNotIn("activeUsers", json.dumps(panel)) + + def paginated_country_client(self): + """Build three sorted pages where the largest category is last.""" + count = 205 + rows = [([f"Country {i:03}"], [i, 10]) for i in range(count)] + pages = [] + for offset in range(0, count, export.PAGE_SIZE): + page = response( + ["screenPageViews", "activeUsers"], + rows[offset : offset + export.PAGE_SIZE], + ["country"], + ) + page.row_count = count + pages.append(page) + client = FakeClient() + client.results[5:6] = pages + return client + + def test_paginated_rankings_preserve_scope_and_denominator(self): + """Pagination must not truncate rankings or the other row.""" + client = self.paginated_country_client() + panel = export.collect(client, SETTINGS, NOW)["breakdowns"][ + "countries" + ] + self.assertEqual(panel["total"], sum(range(205))) + self.assertEqual(panel["rows"][0]["value"], 204) + requests = client.requests[5:8] + self.assertEqual([item["offset"] for item in requests], [0, 100, 200]) + for request in requests: + self.assertEqual( + request["dimension_filter"], + export.dimension_filter(SETTINGS.hosts), + ) + self.assertEqual( + request["order_bys"], + [{"dimension": {"dimension_name": "country"}}], + ) + + def test_bad_pagination_preserves_legacy_snapshot(self): + """Changing pages, duplicate keys and failures are rejected.""" + mutations = [ + lambda page: setattr(page, "row_count", 206), + lambda page: setattr(page.metadata, "time_zone", "UTC"), + lambda page: setattr(page, "rows", []), + lambda page: setattr( + page.rows[0].dimension_values[0], "value", "Country 000" + ), + ] + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "data.json" + path.write_bytes(LEGACY.read_bytes()) + for mutate in mutations: + with self.subTest(mutate=mutate): + client = self.paginated_country_client() + mutate(client.results[6]) + with self.assertRaises(ValueError): + export.refresh(path, client, SETTINGS, NOW) + self.assertEqual(path.read_bytes(), LEGACY.read_bytes()) + client = self.paginated_country_client() + client.results[6] = RuntimeError("Synthetic timeout") + with self.assertRaises(RuntimeError): + export.refresh(path, client, SETTINGS, NOW) + self.assertEqual(path.read_bytes(), LEGACY.read_bytes()) + + def test_explicit_privacy_restriction_is_withheld_not_zero(self): + """Explicit GA4 restrictions produce unavailable panels.""" + for metadata in [ + {"subject_to_thresholding": True}, + { + "schema_restriction_response": Obj( + active_metric_restrictions=[Obj()] + ) + }, + ]: + client = FakeClient() + client.results[5] = response( + ["screenPageViews", "activeUsers"], [], ["country"], **metadata + ) + result = export.collect(client, SETTINGS, NOW) + panel = result["breakdowns"]["countries"] + self.assertEqual(panel["status"], "withheld") + self.assertIsNone(panel["total"]) + self.assertIsNone(panel["other"]) + self.assertEqual(panel["rows"], []) + self.assertEqual(result["summary"]["pageviews"], 120) + self.assertEqual( + result["breakdowns"]["devices"]["status"], "available" + ) + + def test_each_new_query_failure_preserves_snapshot(self): + """No partial v2 upgrade or changed timestamp on failed new queries.""" + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "data.json" + path.write_bytes(LEGACY.read_bytes()) + for index in range(3, 8): + with self.subTest(query=index): + client = FakeClient() + client.results[index] = RuntimeError("Synthetic failure") + with self.assertRaises(RuntimeError): + export.refresh(path, client, SETTINGS, NOW) + self.assertEqual(path.read_bytes(), LEGACY.read_bytes()) + client = FakeClient() + client.results[5].metadata.sampling_metadatas = [Obj()] + with self.assertRaises(ValueError): + 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) + + 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"] + ) + client.results[5] = response( + ["screenPageViews", "activeUsers"], [], ["country"] + ) + result = export.collect(client, SETTINGS, NOW) + self.assertEqual( + result["daily_history"], [{"date": "2026-09-15", "pageviews": 0}] + ) + panel = result["breakdowns"]["countries"] + self.assertEqual(panel["status"], "available") + self.assertEqual(panel["total"], 0) + + +class ExpandedContractTests(unittest.TestCase): + """Version coexistence and closed nested aggregate schemas.""" + + def test_legacy_preview_keeps_document_and_timestamp(self): + """A render is not a new export or a silent schema migration.""" + original = json.loads(LEGACY.read_text()) + with tempfile.TemporaryDirectory() as folder: + config = Obj(extra={}, site_dir=folder) + with patch.dict( + "os.environ", + {"ANALYTICS_PREVIEW_FIXTURE": str(LEGACY)}, + clear=True, + ): + hook.on_config(config) + hook.on_post_build(config) + result = json.loads( + (Path(folder) / "analytics/data.json").read_text() + ) + 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 + ) + + def test_new_nested_schema_rejects_invalid_or_private_data(self): + """Reject invalid dates, counts, labels and private fields.""" + mutations = [ + lambda data: data["comparison"]["reporting_period"].update( + end="2026-08-17" + ), + lambda data: data["comparison"]["summary"].update( + visitor_id="SYNTHETIC" + ), + lambda data: data["daily_history"][0].update(date="2026-02-30"), + lambda data: data["daily_history"][0].update(date="2026-08-16"), + lambda data: data["daily_history"].reverse(), + lambda data: data["daily_history"].append( + data["daily_history"][0] + ), + lambda data: data["breakdowns"]["countries"].update(total=1), + lambda data: data["breakdowns"]["countries"].update( + minimum_active_users=0 + ), + lambda data: data["breakdowns"]["countries"]["rows"][0].update( + label=" {% endblock header_extra %} -{% block content_inner %} +{% 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 %} + +{% 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

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

TEST FIXTURE — LOCAL PREVIEW ONLY. These are synthetic values, not OSL traffic.

+

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

{% endif %} -

OSL-published data · Sourced from Google Analytics

{% if report.status == 'available' %} -
-

Last 30 completed days

-

- - through , inclusive. -

-
-
Pageviews
{{ '{:,}'.format(report.summary.pageviews) }}
-
Active users
{{ '{:,}'.format(report.summary.active_users) }}
-
Sessions
{{ '{:,}'.format(report.summary.sessions) }}
-
-
+
+
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. -

-
- {% else %} -
-

Analytics data is not available yet

-

We have not published a successful GA4 report. Reporting dates, timezone, hostname scope, and audience figures will appear after the first refresh. Missing data does not mean zero visits.

+

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 %} + +
+

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 }}

+
+ {% 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 %}
- {% endif %} -

- Download JSON report - Sponsor Open Science Labs -

- {% if report.status == 'available' %} -
-

Monthly pageviews

-

Up to 12 completed months. Requested range: {{ report.history_period.start }} through {{ report.history_period.end }} ({{ report.timezone | e }}).

- {% if report.monthly_history %} - {% set max_views = report.monthly_history | map(attribute='pageviews') | max %} -
-
Pageviews by month · exact values and dates in the table below.
-
-
- - - - - {% for item in report.monthly_history %} - - - - - + + {{ trend(view.daily, 'daily', 'Daily pageviews') }} + {{ trend(view.monthly, 'monthly', 'Monthly pageviews', true) }} + + +
+

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 %} -
-
Monthly pageviews. Dates are inclusive in {{ report.timezone | e }}.
MonthReporting datesPageviews
{{ '{:,}'.format(item.pageviews) }}
+ +

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 %} +
+ {% endfor %}
- {% else %} -

No completed-month history is available for this scope. No monthly values have been inferred.

- {% endif %} +

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 }}
+
+
-{{ page.content }} -{% endblock content_inner %} +{% endblock content %} diff --git a/theme/css/analytics.css b/theme/css/analytics.css index c32c0174..8834b0f2 100644 --- a/theme/css/analytics.css +++ b/theme/css/analytics.css @@ -1,87 +1,592 @@ -/* Uses the shared lit/dim theme tokens; no chart library or external assets. */ -.osl-page--section-analytics { - /* The about palette is too pale for small links on light surfaces. */ - --osl-accent: color-mix(in srgb, var(--brand-strong) 85%, var(--fg)); +/* 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; + color: var(--fg); + font-variant-numeric: tabular-nums; +} +html[data-mode="dim"] .analytics-dashboard { + --analytics-blue: var(--brand-strong); + --analytics-violet: #b8a7ff; + --analytics-green: #7bd9b7; +} +@media (prefers-color-scheme: dark) { + html:not([data-mode="lit"]) .analytics-dashboard { + --analytics-blue: var(--brand-strong); + --analytics-violet: #b8a7ff; + --analytics-green: #7bd9b7; + } +} +.analytics-dashboard * { + box-sizing: border-box; +} +.analytics-dashboard [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 { + color: var(--analytics-blue); +} +.analytics-dashboard h2 { + font-size: clamp(1.3rem, 2.4vw, 1.65rem); + line-height: 1.3; +} +.analytics-dashboard h3 { + font-size: 1.05rem; + line-height: 1.4; +} +.analytics-dashboard p { + line-height: 1.6; +} +.analytics-dashboard a { + color: var(--analytics-blue); +} +.analytics-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 2rem; + margin-bottom: 2rem; } -.analytics-report .osl-inline-button { +.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-intro { + font-size: 1.05rem; + margin: 1rem 0 0.4rem; + color: var(--muted); +} +.analytics-source { + font-size: 0.78rem; + color: var(--muted); + margin: 0; +} +.analytics-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.9rem; + flex-shrink: 0; +} +.analytics-dashboard .analytics-button { + display: inline-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); - border-color: var(--osl-accent); - color: var(--osl-accent) !important; + font-size: 0.86rem; + font-weight: 650; + text-decoration: none; + min-height: 44px; +} +.analytics-button:hover { + box-shadow: 0 0 0 2px var(--analytics-blue); +} +.analytics-sponsor { + font-size: 0.8rem; + text-underline-offset: 4px; +} +.analytics-period-strip { + padding: 1.1rem 1.4rem; + 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; + font-weight: 650; +} +.analytics-period-strip .analytics-eyebrow { + font-size: 0.65rem; + margin-bottom: 0.4rem; +} +.analytics-metadata { + display: flex; + gap: 1rem 1.8rem; + flex-wrap: wrap; + margin: 0; +} +.analytics-metadata dt { + font-size: 0.65rem; + color: var(--muted); + font-weight: 500; +} +.analytics-metadata dd { + font-size: 0.73rem; + margin: 0.25rem 0 0; + overflow-wrap: anywhere; +} +.analytics-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin: 2rem 0 1.1rem; +} +.analytics-section-heading > a { + font-size: 0.75rem; +} +.analytics-section-heading .analytics-eyebrow { + margin-bottom: 0.45rem; } -.analytics-report { min-width: 0; } -.analytics-source { color: var(--muted); font-weight: 600; } .analytics-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; - margin: 1.5rem 0; } -.analytics-metrics > div { - padding: 1.3rem; +.analytics-metric { + --metric-color: var(--analytics-blue); + padding: 1.5rem; background: var(--surface-solid); - border: 1px solid var(--border-strong); - border-top: 4px solid var(--brand-strong); - border-radius: 12px; + border: 1px solid var(--border); + border-top: 3px solid var(--metric-color); + border-radius: 0.8rem; + min-width: 0; +} +.analytics-metric--active_users { + --metric-color: var(--analytics-violet); +} +.analytics-metric--sessions { + --metric-color: var(--analytics-green); +} +.analytics-metric dl { + margin: 0; } -.analytics-metrics dt { color: var(--muted); font-weight: 600; } -.analytics-metrics dd { - margin: .4rem 0 0; +.analytics-metric dt { + color: var(--muted); + font-size: 0.85rem; + font-weight: 600; +} +.analytics-metric dd { color: var(--heading); - font-size: clamp(1.6rem, 3vw, 2.5rem); - font-weight: 700; - font-variant-numeric: tabular-nums; + font-size: clamp(2.2rem, 4.5vw, 3.2rem); + line-height: 1.3; + font-weight: 720; + letter-spacing: -0.045em; + margin: 0.5rem 0 0.7rem; + 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); +} +.analytics-definition { + font-size: 0.73rem; + color: var(--muted); + padding-top: 0.9rem; + margin: 1rem 0 0; + border-top: 1px solid var(--border); +} +.analytics-footnote { + color: var(--muted); + font-size: 0.74rem; + line-height: 1.7; + margin: 0.85rem 0; +} +.analytics-panel { + 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; +} +.analytics-chart-switch { + display: flex; + border-radius: 0.6rem; + border: 1px solid var(--border); + background: var(--surface-muted); + padding: 0.25rem; + gap: 0.2rem; +} +.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; + color: var(--muted); + background: transparent; +} +.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-chart-heading { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; +} +.analytics-chart-heading > span { + font-size: 0.7rem; + color: var(--muted); +} +.analytics-trend-panel + .analytics-trend-panel { + margin-top: 1.6rem; +} +.analytics-trend-panel[hidden] + .analytics-trend-panel { + margin-top: 0; +} +.analytics-chart { + margin: 0.8rem 0; +} +.analytics-chart svg { + width: 100%; + height: auto; + display: block; + overflow: visible; +} +.analytics-gridline { + stroke: var(--border); + 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); + stroke-width: 2.5; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} +.analytics-point { + fill: var(--analytics-blue); +} +.analytics-bar { + fill: var(--analytics-violet); + opacity: 0.85; +} +.analytics-chart figcaption { + color: var(--muted); + font-size: 0.72rem; +} +.analytics-audience-grid { + display: grid; + grid-template-columns: 1.25fr 1fr 1fr; + gap: 1rem; +} +.analytics-panel-description { + color: var(--muted); + font-size: 0.77rem; + margin: 0.5rem 0 1.3rem; +} +.analytics-ranking { + list-style: none; + padding: 0; + margin: 1.2rem 0; +} +.analytics-ranking li { + margin-bottom: 1.1rem; +} +.analytics-ranking-label { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.8rem; + font-size: 0.8rem; +} +.analytics-ranking-label > :first-child { overflow-wrap: anywhere; } -.analytics-details > div { margin-bottom: .8rem; } -.analytics-details dt { font-weight: 600; } -.analytics-details dd { margin: .2rem 0 0; overflow-wrap: anywhere; } -.analytics-notice, .analytics-empty { - padding: 1rem 1.25rem; - border: 1px solid var(--border-strong); - border-left: 4px solid var(--accent); +.analytics-ranking-label > :last-child { + color: var(--muted); + flex-shrink: 0; + font-size: 0.73rem; +} +.analytics-track { + height: 6px; border-radius: 8px; - background: var(--surface-warm); + background: var(--surface-muted); + margin: 0.45rem 0 0.15rem; + overflow: hidden; +} +.analytics-track > span { + display: block; + height: 100%; + background: var(--analytics-blue); + border-radius: inherit; +} +.analytics-breakdown--devices .analytics-track > span { + background: var(--analytics-violet); +} +.analytics-breakdown--channels .analytics-track > span { + background: var(--analytics-green); +} +.analytics-ranking li:last-child .analytics-track > span { + background: var(--muted); +} +.analytics-count { + color: var(--muted); + font-size: 0.75rem; +} +.analytics-data-table { + border-top: 1px solid var(--border); + padding-top: 0.85rem; + margin-top: 1rem; +} +.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-wrap { + max-width: 100%; + overflow-x: auto; + margin-top: 0.8rem; +} +.analytics-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} +.analytics-table th, +.analytics-table td { + text-align: left; + padding: 0.65rem 0.5rem; + border-bottom: 1px solid var(--border); color: var(--fg); } -.analytics-report [hidden] { display: none !important; } -.analytics-actions { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; } -.analytics-chart { - padding: 1.25rem; - margin: 1.5rem 0; - border: 1px solid var(--border-strong); - border-radius: 12px; +.analytics-table td { + text-align: right; +} +.analytics-table caption { + caption-side: top; + text-align: left; + color: var(--muted); + font-size: 0.72rem; + padding: 0.5rem; +} +.analytics-table thead th { + color: var(--muted); + font-weight: 600; +} +.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-fixture { + border-left-color: var(--analytics-violet); +} +.analytics-panel-empty { + color: var(--muted); + font-size: 0.83rem; + padding: 1rem; + background: var(--surface-muted); + border-radius: 0.6rem; +} +.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-chart figcaption { color: var(--muted); margin-bottom: 1rem; } -.analytics-chart-row { - display: grid; - grid-template-columns: 5rem minmax(0, 1fr) 5rem; - gap: .75rem; +.analytics-empty > p { + max-width: 55ch; +} +.analytics-methodology { + margin-top: 2rem; +} +.analytics-methodology > div > p:last-child { + color: var(--muted); + max-width: 90ch; + font-size: 0.85rem; + margin-bottom: 0.8rem; +} +.analytics-methodology-content { + margin-top: 1rem; + font-size: 0.86rem; + max-width: 85ch; +} +.analytics-methodology-content h2, +.analytics-methodology-content h3 { + margin: 1.4rem 0 0.8rem; +} +.analytics-methodology-content li { + line-height: 1.8; + margin-bottom: 0.65rem; +} +.analytics-support { + display: flex; align-items: center; - margin: .55rem 0; - font-variant-numeric: tabular-nums; + justify-content: space-between; + gap: 2rem; + padding: 2rem 0 1rem; } -.analytics-chart-row > :last-child { text-align: right; overflow-wrap: anywhere; } -.analytics-chart-track { background: var(--surface-muted); border-radius: 3px; } -.analytics-chart-bar { - display: block; - height: 1rem; - border-radius: 3px; - background: var(--brand-strong); -} -.analytics-table-wrap { max-width: 100%; overflow-x: auto; margin-bottom: 2rem; } -.analytics-table { font-variant-numeric: tabular-nums; } -.analytics-table caption { caption-side: top; color: var(--muted); } -.analytics-table th, .analytics-table td { color: var(--fg); padding: .65rem; } -.analytics-table td:last-child, .analytics-table th:last-child { text-align: right; } -.analytics-report a:focus-visible, .analytics-table-wrap:focus-visible { - outline: 3px solid var(--brand-strong); +.analytics-support h2 { + font-size: 1.3rem; +} +.analytics-support p { + color: var(--muted); + font-size: 0.82rem; +} +.analytics-support .analytics-button { + flex-shrink: 0; +} +.analytics-dashboard a:focus-visible, +.analytics-dashboard button:focus-visible, +.analytics-dashboard summary:focus-visible, +.analytics-table-wrap:focus-visible { + outline: 3px solid var(--analytics-blue); outline-offset: 4px; } +.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 (max-width: 900px) { + .analytics-audience-grid { + grid-template-columns: 1fr 1fr; + } + .analytics-breakdown--countries { + grid-column: 1 / -1; + } + .analytics-breakdown--countries .analytics-ranking { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 2rem; + } + .analytics-metadata { + gap: 1rem; + } + .analytics-metric { + padding: 1.15rem; + } +} @media (max-width: 600px) { - .analytics-metrics { grid-template-columns: 1fr; gap: .6rem; } - .analytics-metrics > div { padding: 1rem; } - .analytics-chart { padding: .85rem; } - .analytics-chart-row { grid-template-columns: 4.5rem minmax(0, 1fr) 4rem; gap: .4rem; font-size: .85rem; } - .analytics-table { font-size: .85rem; } + .analytics-header, + .analytics-support { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + .analytics-actions { + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + } + .analytics-period-strip { + padding: 1rem; + } + .analytics-metrics, + .analytics-audience-grid { + grid-template-columns: 1fr; + } + .analytics-metric { + padding: 1.2rem; + } + .analytics-metric dd { + font-size: 2.6rem; + } + .analytics-section-heading { + align-items: flex-start; + flex-direction: column; + gap: 0.65rem; + } + .analytics-panel { + padding: 1.1rem; + } + .analytics-chart-heading { + align-items: flex-start; + flex-direction: column; + gap: 0.3rem; + } + .analytics-breakdown--countries .analytics-ranking { + display: block; + } + .analytics-chart svg { + min-width: 0; + } + .analytics-axis { + font-size: 26px; + } + .analytics-chart-switch { + width: 100%; + } + .analytics-chart-switch button { + flex: 1; + min-height: 44px; + padding: 0.5rem; + } +} +@media (prefers-reduced-motion: reduce) { + .analytics-dashboard * { + scroll-behavior: auto; + transition: none; + } } diff --git a/theme/js/analytics.js b/theme/js/analytics.js index 962e57a5..dcd7b288 100644 --- a/theme/js/analytics.js +++ b/theme/js/analytics.js @@ -1,5 +1,24 @@ /* Progressive enhancement only: figures, dates and table are static HTML. */ (() => { + 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; + }); + }; + buttons.forEach((button) => + button.addEventListener("click", () => + select(button.dataset.chartTarget), + ), + ); + select(switcher.dataset.defaultChart); + switcher.hidden = false; + } const refreshed = document.getElementById("analytics-refreshed"); const notice = document.getElementById("analytics-stale"); if (!refreshed || !notice) return;