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) %}
+ Requested dates: {{ graph.period.start }} through {{ graph.period.end }}, inclusive. {{ 'Completed-month' if monthly else 'Daily' }} history is not available in this snapshot. No values have been inferred.{{ title }}
Pageviews · zero-based scaleView {{ 'monthly' if monthly else 'daily' }} data table
+
+
+
+ {% for point in graph.slots %}{{ 'Month' if monthly else 'Date' }} {% if monthly %}Reporting dates {% endif %}Pageviews {% endfor %}
+ {% if monthly %} – {% endif %}{{ '{:,}'.format(point.value) if point.value is not none else 'Not reported' }}
Open Science Labs / Transparency
+A shared view of the people discovering open science.
+OSL-published data · Sourced from Google Analytics
+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' %} -- - through , inclusive. -
-through
- Stale data. The last successful refresh was more than three days ago. These are the last available figures, not a current report. -
-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 %} + +{{ metric.text }}
+ {% if metric.previous is not none %}Previous 30 days: {{ '{:,}'.format(metric.previous) }}
{% endif %} +{{ metric.definition }}
+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 %}- Download JSON report - Sponsor Open Science Labs -
- {% if report.status == 'available' %} -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 %} -