Skip to content

feat(stats): show Plausible visitors chart and daily-impl timeline#6608

Merged
MarkusNeusinger merged 9 commits into
mainfrom
claude/add-plausible-chart-7voOx
May 15, 2026
Merged

feat(stats): show Plausible visitors chart and daily-impl timeline#6608
MarkusNeusinger merged 9 commits into
mainfrom
claude/add-plausible-chart-7voOx

Conversation

@MarkusNeusinger
Copy link
Copy Markdown
Owner

@MarkusNeusinger MarkusNeusinger commented May 13, 2026

⚠️ DO NOT MERGE until PLAUSIBLE_API_KEY exists in GCP Secret Manager.
api/cloudbuild.yaml wires this secret into Cloud Run via --set-secrets, so
merging before the secret is provisioned will fail the deploy on main.
Create it with: gcloud secrets create PLAUSIBLE_API_KEY --data-file=- and
ensure the Cloud Run runtime service account has roles/secretmanager.secretAccessor.

Summary

  • Visitors bar chart on /stats — replaces the plain "visitor analytics at plausible.io/anyplot.ai" link at the top of the stats page with a 30-day unique-visitors bar chart styled like the other in-page charts. A more → link still points to the full Plausible dashboard for deeper analytics.
  • Daily timeline like the debug page — swaps the monthly timeline for a 30-day "implementations updated per day" bar chart, visually mirroring the activity strip on the debug dashboard so public visitors can see catalog activity at a glance. (Schema-wise it uses count to match TimelinePoint, not debug's impls_updated field — see DailyImplPoint docstring.)
  • New backend endpoint GET /insights/visitors queries Plausible's Stats API v2 (POST https://plausible.io/api/v2/query with { metrics: ["visitors"], date_range: "30d", dimensions: ["time:day"] }), cached 1h via stale-while-revalidate so we stay well below Plausible's 600 req/h limit. When PLAUSIBLE_API_KEY is unset or the upstream fails, it returns points: [] and the frontend shows a "visitor data unavailable" placeholder — distinguishing real zeros from missing data. The rest of the dashboard is unaffected because visitors load on a separate fetch.
  • daily_impls added to /insights/dashboard — zero-filled last-30-days series so the daily timeline does not need an additional admin-only endpoint.

Configuration

Adds three settings to core.config.settings (also in .env.example):

  • PLAUSIBLE_API_KEY — Bearer token from Plausible → Account Settings → API Keys (optional; without it the chart degrades gracefully)
  • PLAUSIBLE_SITE_ID — defaults to anyplot.ai
  • PLAUSIBLE_API_URL — defaults to https://plausible.io/api/v2/query

Pre-merge checklist

  • Create PLAUSIBLE_API_KEY in GCP Secret Manager
  • Grant roles/secretmanager.secretAccessor to the Cloud Run runtime service account
  • Remove do-not-merge label

Test plan

  • uv run --extra test pytest tests/unit/api/test_routers.py tests/unit/api/test_insights_helpers.py — passes (incl. tests for the no-API-key path, upstream-failure path, and the Plausible response parsing with a frozen clock)
  • yarn test (app/) — passes (incl. new tests for the visitors header label and the "unavailable" placeholder)
  • yarn type-check and yarn lint — clean
  • yarn build — succeeds
  • uv run --extra dev ruff check / ruff format --check / mypy api core — all clean
  • After deploy: confirm /insights/visitors returns real data once PLAUSIBLE_API_KEY is provisioned
  • After deploy: visit /stats and verify the visitors chart renders and the daily-impls timeline matches the debug page

Generated by Claude Code

- Add GET /insights/visitors that queries the Plausible Stats API
  (POST /api/v2/query, time:day dimension) for unique visitors over
  the last 30 days. Cached 1h via stale-while-revalidate to stay well
  under Plausible's 600 req/h limit; returns a zero-filled series when
  PLAUSIBLE_API_KEY is unset or the upstream call fails.
- Add daily_impls (last 30 days, zero-filled) to /insights/dashboard
  so the public stats timeline mirrors the debug page activity strip.
- Replace the plain Plausible link at the top of StatsPage with a
  30-day visitors bar chart styled like the rest of the page; keep
  a "more →" link out to plausible.io/anyplot.ai for deeper analytics.
- Swap the monthly timeline for the new daily updated-implementations
  bar chart so visitors see catalog activity at a glance.
Copilot AI review requested due to automatic review settings May 13, 2026 20:26
claude and others added 2 commits May 13, 2026 20:28
CI's `ruff format --check` flagged minor whitespace in the visitors block.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds public-facing analytics to /stats by introducing a Plausible-backed visitors time series endpoint and switching the dashboard timeline visualization to a last-30-days “implementations updated” strip, with supporting config/docs/tests and Cloud Run wiring.

Changes:

  • Add GET /insights/visitors (cached) to query Plausible Stats API v2 and return a last-30-days visitors series.
  • Extend /insights/dashboard with daily_impls (last 30 days, zero-filled) and update the StatsPage to render both the visitors chart and daily timeline.
  • Add configuration knobs/documentation and update unit/frontend tests; wire PLAUSIBLE_API_KEY via Cloud Build secrets.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
api/routers/insights.py Adds Plausible visitors endpoint and extends dashboard response with daily_impls.
core/config.py Introduces Plausible Stats API settings (PLAUSIBLE_*).
app/src/pages/StatsPage.tsx Renders visitors bar chart and switches timeline to daily 30-day updates.
app/src/pages/StatsPage.test.tsx Updates StatsPage test fixtures for the new dashboard shape.
tests/unit/api/test_routers.py Adds unit tests for /insights/visitors fallback and parsing behavior.
docs/reference/plausible.md Documents backend consumption of Plausible Stats API for the stats page.
.env.example Documents Plausible-related environment variables.
api/cloudbuild.yaml Wires PLAUSIBLE_API_KEY from Secret Manager into Cloud Run deploy.
Comments suppressed due to low confidence (1)

app/src/pages/StatsPage.tsx:374

  • This timeline chart renders a non-zero bar even when point.count is 0 (min 3% height + minHeight 2). The debug page’s activity strip (which this claims to mirror) renders 0-height bars for zero days. To truly mirror the debug behavior and avoid implying activity on zero days, apply a conditional min height/percent only when count > 0.
            <Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 0.25, height: 70, overflow: 'hidden' }}>
              {dailyImpls.map(point => (
                <Tooltip key={point.date} title={`${point.date}: ${point.count} updated`} arrow>
                  <Box sx={{
                    flex: 1,
                    height: `${Math.max((point.count / maxDaily) * 100, 3)}%`,
                    bgcolor: colors.primaryDark,
                    opacity: 0.5,
                    borderRadius: '2px 2px 0 0',
                    minHeight: 2,
                    '&:hover': { opacity: 0.8 },

Comment thread app/src/pages/StatsPage.tsx Outdated
<Box sx={{ mt: 1, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontFamily: typography.fontFamily, fontSize: fontSize.xs, color: semanticColors.mutedText }}>
unique visitors · last 30 days{visitors !== null && visitorPoints.length > 0 ? ` · ${formatNum(totalVisitors)} total` : ''}
Comment on lines +196 to +207
{visitors === null ? (
<Box sx={{ height: 70, display: 'flex', alignItems: 'center' }}>
<Typography sx={{ fontFamily: typography.fontFamily, fontSize: fontSize.xxs, color: semanticColors.mutedText }}>
loading visitor data...
</Typography>
</Box>
) : visitorPoints.length === 0 ? (
<Box sx={{ height: 70, display: 'flex', alignItems: 'center' }}>
<Typography sx={{ fontFamily: typography.fontFamily, fontSize: fontSize.xxs, color: semanticColors.mutedText }}>
visitor data unavailable — see plausible.io/anyplot.ai
</Typography>
</Box>
Comment thread api/routers/insights.py Outdated
Comment on lines +624 to +635
async def _fetch_plausible_visitors() -> VisitorsResponse:
"""Query the Plausible Stats API v2 for unique visitors per day (last 30d).

Returns an empty series when the API key is not configured or the upstream
call fails — the stats page treats this as "no data" rather than erroring.
The response is zero-filled so the frontend can render a stable 30-bar
chart even on days Plausible has not seen any visitors.
"""
today = datetime.now(timezone.utc).date()
zero_filled = [
VisitorPoint(date=(today - timedelta(days=offset)).isoformat(), visitors=0) for offset in range(29, -1, -1)
]
Comment on lines +65 to +69
daily_impls: [
{ date: '2026-04-14', count: 3 },
{ date: '2026-04-15', count: 5 },
{ date: '2026-04-16', count: 0 },
],
Comment thread core/config.py Outdated
plausible_api_key: str | None = None
"""Plausible Analytics Stats API key (Bearer token) used by /insights/visitors
to fetch unique visitors per day for the public stats page. When unset, the
endpoint returns an empty result set so the frontend can degrade gracefully."""
Comment thread .env.example Outdated
Comment on lines +72 to +73
# When unset, /insights/visitors returns an empty series (the bar chart shows
# "no data" instead of failing).
Comment thread docs/reference/plausible.md Outdated
Comment on lines +594 to +597
upstream call fails, the endpoint returns a zero-filled 30-day series
and the frontend renders a "visitor data unavailable" placeholder
instead of erroring. The dashboard endpoint is unaffected because
visitors load on a separate fetch.
Comment thread api/cloudbuild.yaml
Comment on lines +67 to +71
# PLAUSIBLE_API_KEY: bearer token for the Plausible Stats API (powers
# /insights/visitors on the public stats page). The Secret Manager
# entry must exist before the first deploy that includes this line —
# create it with: gcloud secrets create PLAUSIBLE_API_KEY --data-file=-
- "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest,PLAUSIBLE_API_KEY=PLAUSIBLE_API_KEY:latest"
Comment thread api/routers/insights.py
Comment on lines +91 to +96
class DailyImplPoint(BaseModel):
"""Implementation updates on a single day (last-30-days timeline)."""

date: str # ISO "YYYY-MM-DD"
count: int

@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 91.35802% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
api/routers/insights.py 92.00% 4 Missing ⚠️
app/src/pages/StatsPage.tsx 90.00% 2 Missing ⚠️
app/src/components/SectionHeader.tsx 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

MarkusNeusinger and others added 2 commits May 13, 2026 22:35
- Backend now returns `points: []` when PLAUSIBLE_API_KEY is unset or the
  upstream call fails, instead of a 30-point zero-filled series. The
  frontend already distinguishes empty (placeholder) from real-zeros
  (chart), so this restores the documented degradation behavior — an
  all-zero chart was being shown where the "visitor data unavailable"
  placeholder should have appeared.
- Update docstrings/comments in core/config.py, .env.example,
  docs/reference/plausible.md, and api/routers/insights.py to describe
  the empty-list contract accurately.
- Relabel the visitors total from "X total" to "X daily-uniques sum"
  since summing per-day uniques over-counts returning visitors and is
  not the true 30-day unique-visitor count.
- Clarify DailyImplPoint's docstring: it intentionally uses `count`
  (matching the existing TimelinePoint) rather than debug's
  `impls_updated`, since the two have different consumers.
- Test mock now routes by URL so the visitors fetch isn't silently
  shadowed by the dashboard payload, plus new assertions for the
  visitors header/total label and the unavailable placeholder.
- Add a backend test for the upstream-failure branch.
Copilot AI review requested due to automatic review settings May 13, 2026 20:37
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Comment on lines +1794 to +1800
def test_visitors_parses_plausible_response(self, client: TestClient) -> None:
"""Visitor counts from Plausible should be merged into the zero-filled 30-day series."""
from datetime import datetime as _dt
from datetime import timezone as _tz

today_iso = _dt.now(_tz.utc).date().isoformat()

Comment thread api/routers/insights.py Outdated
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.warning("Plausible visitors fetch failed (returning empty series): %s", e)
Comment on lines +593 to +599
- **Graceful degradation**: When `PLAUSIBLE_API_KEY` is unset or the
upstream call fails, the endpoint returns `points: []` (empty list).
The frontend distinguishes this from "real zeros" — an empty list
triggers the "visitor data unavailable" placeholder, while a non-empty
list with low/zero values renders the normal 30-bar chart. The
dashboard endpoint is unaffected because visitors load on a separate
fetch.
Comment thread api/cloudbuild.yaml
Comment on lines +67 to +71
# PLAUSIBLE_API_KEY: bearer token for the Plausible Stats API (powers
# /insights/visitors on the public stats page). The Secret Manager
# entry must exist before the first deploy that includes this line —
# create it with: gcloud secrets create PLAUSIBLE_API_KEY --data-file=-
- "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest,PLAUSIBLE_API_KEY=PLAUSIBLE_API_KEY:latest"
Comment on lines +85 to 100
function mockFetchSuccess(visitorsPayload: { points: Array<{ date: string; visitors: number }> } | null = mockVisitors) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockDashboard),
vi.fn().mockImplementation((url: string) => {
if (url.includes('/insights/visitors')) {
return Promise.resolve({
ok: visitorsPayload !== null,
json: () => Promise.resolve(visitorsPayload ?? { points: [] }),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockDashboard),
});
}),
);
- Preserve stack context on Plausible fetch failure by switching the
  warning log to `exc_info=True` (matches the cache.py pattern).
- Stop the test_visitors_parses_plausible_response test from being
  flaky around UTC midnight by patching `datetime` in the module under
  test to a frozen value (so the endpoint's "today" matches the date
  the fake Plausible response references).
- Add `afterEach(vi.unstubAllGlobals)` to StatsPage.test.tsx so the
  stubbed `fetch` doesn't leak into other test suites in the same
  vitest worker.
Copilot AI review requested due to automatic review settings May 15, 2026 21:29
MarkusNeusinger and others added 3 commits May 15, 2026 23:30
- Move the Plausible link into the canonical SectionHeader link slot as
  plausible.view() so it matches the libraries.all() / map.explore() /
  specs.all() style used across the site; SectionHeader gains a
  linkHref prop for external URLs (target=_blank, fires external_link).
- Align the visitors window with Plausible's default 28-day report so
  totals here line up with plausible.io/anyplot.ai. Bring the dashboard
  daily_impls timeline to 28d too so the two bar strips read
  side-by-side. Caption switched from "daily-uniques sum" -> "total".
- Document the now-provisioned PLAUSIBLE_API_KEY in plausible-auditor.md:
  env -> .env -> Secret Manager fallback chain, never-block contract,
  v1 + v2 connectivity check, and broaden allowed endpoints to include
  the v2 query POST the backend actually uses for /insights/visitors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Expanded list of supported languages
- Clarified notes on language server requirements
- Added sections for excluded and included tools
- Introduced additional workspace folder paths for cross-package reference
@MarkusNeusinger MarkusNeusinger enabled auto-merge (squash) May 15, 2026 21:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

app/src/components/SectionHeader.tsx:86

  • The new linkHref tracking sets destination to the full URL. Elsewhere in the app, destination values are low-cardinality identifiers (e.g. github_releases, library_docs), which keeps analytics dimensions tidy. Consider tracking a stable identifier here as well (e.g. hostname or a caller-provided destination key), rather than the full URL string.
          href={linkHref}
          target="_blank"
          rel="noopener noreferrer"
          onClick={() => trackEvent('external_link', { source: 'section_header', destination: linkHref })}
          sx={linkSx}

app/src/components/SectionHeader.tsx:83

  • linkHref adds a new rendering + analytics path (external_link event) but SectionHeader’s test suite currently only covers the linkTo/nav_click branch. Please add a unit test that renders linkHref, clicks the link, and asserts the expected external_link tracking props so this behavior doesn’t regress silently.
      {linkText && linkHref && !linkTo && (
        <Box
          component="a"
          href={linkHref}
          target="_blank"

Comment thread api/routers/insights.py


class DailyImplPoint(BaseModel):
"""Implementation updates on a single day (last-30-days timeline).
Comment on lines 10 to +14
linkText?: string;
/** Internal route (React Router). Mutually exclusive with `linkHref`. */
linkTo?: string;
/** External URL — opens in a new tab. Mutually exclusive with `linkTo`. */
linkHref?: string;
@MarkusNeusinger MarkusNeusinger merged commit e7b57bc into main May 15, 2026
9 checks passed
@MarkusNeusinger MarkusNeusinger deleted the claude/add-plausible-chart-7voOx branch May 15, 2026 21:36
MarkusNeusinger added a commit that referenced this pull request May 16, 2026
## Summary
- Rescues an orphan post-merge commit (`3a955a4`) from
`claude/add-plausible-chart-7voOx`. The original PR #6608 was
squash-merged, then a Copilot review-feedback fix was pushed to the
(closed) branch and never landed on main.
- All three changes are still missing on main — verified by diffing
`origin/main..origin/claude/add-plausible-chart-7voOx`.

## Changes
- **`api/routers/insights.py`** — fix `DailyImplPoint` docstring:
`last-30-days` → `last-28-days` to match the visitors-chart window the
strip is paired with.
- **`app/src/components/SectionHeader.tsx`** — convert `linkTo` /
`linkHref` to a discriminated union so callers can no longer pass both.
Extract `externalDestination()` helper to send only the hostname to
Plausible (low-cardinality dimension) instead of the full URL.
- **`app/src/components/SectionHeader.test.tsx`** — add a unit test for
the `linkHref` branch and the `external_link` tracking payload.

## Test plan
- [x] `yarn test SectionHeader.test.tsx` passes (3/3)
- [ ] CI green
- [ ] `claude/add-plausible-chart-7voOx` can be deleted after merge

Co-authored-by: Claude <noreply@anthropic.com>
MarkusNeusinger added a commit that referenced this pull request May 18, 2026
Version bump for the v2.4.0 release. Release notes will be attached to
the tag once this lands.

## Highlights since v2.3.0

- **R / ggplot2 added as the 10th library** + multi-language pipeline
(#6944, #6961, #7052). 30 ggplot2 implementations landed across
foundational plot types.
- **In-app feedback widget** (#7143).
- **Stats page** with Plausible visitors chart + daily-impl timeline
(#6608).
- **Language across the site**: `/plots?lang=` filtering, cross-language
carousel, language in URLs and titles (#7141, #7142, #7144).
- **UI polish**: pseudo-function styling for 404 / footer / empty state
/ library card (#6436); mobile fixes for `/stats`, `/mcp`, breadcrumb +
FAB (#6902, #7283).
- **Pipeline**: review-retry listener + stuck-jobs watchdog (#6084);
daily-regen 2h → hourly (#6943).
- **Dependencies**: mypy 1.20→2.1, urllib3 2.6→2.7, authlib bump,
react/mui/python-minor groups.
- ~1200 implementation regenerations across all 10 libraries.

No SemVer-breaking changes.

**Full Changelog:**
v2.3.0...main

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants