diff --git a/.env.example b/.env.example
index 0f15b2ee1b..6be2999a0b 100644
--- a/.env.example
+++ b/.env.example
@@ -64,6 +64,20 @@ PORT=8000
# Anthropic API key for Claude
# ANTHROPIC_API_KEY=sk-ant-...
+# ============================================================================
+# Plausible Stats API (for /insights/visitors on the stats page)
+# ============================================================================
+
+# Bearer token created in Plausible -> Account Settings -> API Keys.
+# When unset (or when the upstream call fails), /insights/visitors returns
+# `points: []` so the stats page can render a "visitor data unavailable"
+# placeholder instead of an all-zero chart.
+# PLAUSIBLE_API_KEY=
+
+# Optional overrides — defaults: site_id="anyplot.ai", url=plausible.io v2 query
+# PLAUSIBLE_SITE_ID=anyplot.ai
+# PLAUSIBLE_API_URL=https://plausible.io/api/v2/query
+
# ============================================================================
# CLI Model Tier Configuration (optional)
# ============================================================================
diff --git a/.serena/project.yml b/.serena/project.yml
index 2e9f5aba12..4f7cb19597 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -1,23 +1,30 @@
+
+
# list of languages for which language servers are started; choose from:
-# al bash clojure cpp csharp
-# csharp_omnisharp dart elixir elm erlang
-# fortran fsharp go groovy haskell
-# java julia kotlin lua markdown
-# matlab nix pascal perl php
-# powershell python python_jedi r rego
-# ruby ruby_solargraph rust scala swift
-# terraform toml typescript typescript_vts vue
-# yaml zig
+# al angular ansible bash clojure
+# cpp cpp_ccls crystal csharp csharp_omnisharp
+# dart elixir elm erlang fortran
+# fsharp go groovy haskell haxe
+# hlsl html java json julia
+# kotlin lean4 lua luau markdown
+# matlab msl nix ocaml pascal
+# perl php php_phpactor powershell python
+# python_jedi python_ty r rego ruby
+# ruby_solargraph rust scala scss solidity
+# swift systemverilog terraform toml typescript
+# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
+# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
+# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
-# - csharp: Requires the presence of a .sln file in the project folder.
-# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
@@ -32,8 +39,9 @@ encoding: "utf-8"
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
-# list of additional paths to ignore in all projects
-# same syntax as gitignore, so you can use * and **
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
@@ -41,45 +49,9 @@ ignored_paths: []
# Added on 2025-04-18
read_only: false
-# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
-# Below is the complete list of tools for convenience.
-# To make sure you have the latest list of tools, and to view their descriptions,
-# execute `uv run scripts/print_tool_overview.py`.
-#
-# * `activate_project`: Activates a project by name.
-# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
-# * `create_text_file`: Creates/overwrites a file in the project directory.
-# * `delete_lines`: Deletes a range of lines within a file.
-# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
-# * `execute_shell_command`: Executes a shell command.
-# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
-# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
-# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
-# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
-# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
-# * `initial_instructions`: Gets the initial instructions for the current project.
-# Should only be used in settings where the system prompt cannot be set,
-# e.g. in clients you have no control over, like Claude Desktop.
-# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
-# * `insert_at_line`: Inserts content at a given line in a file.
-# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
-# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
-# * `list_memories`: Lists memories in Serena's project-specific memory store.
-# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
-# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
-# * `read_file`: Reads a file within the project directory.
-# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
-# * `remove_project`: Removes a project from the Serena configuration.
-# * `replace_lines`: Replaces a range of lines within a file with new content.
-# * `replace_symbol_body`: Replaces the full definition of a symbol.
-# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
-# * `search_for_pattern`: Performs a search for a pattern in the project.
-# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
-# * `switch_modes`: Activates modes by providing a list of their names
-# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
-# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
-# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
-# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -88,11 +60,14 @@ initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "anyplot"
-# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
@@ -103,11 +78,14 @@ fixed_tools: []
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
-# list of mode names that are to be activated by default.
-# The full set of modes to be activated is base_modes + default_modes.
-# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# list of mode names that are to be activated by default, overriding the setting in the global configuration.
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
+# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# time budget (seconds) per tool call for the retrieval of additional symbol information
@@ -145,3 +123,19 @@ ignored_memory_patterns: []
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
+
+# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+added_modes:
+
+# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
+# Paths can be absolute or relative to the project root.
+# Each folder is registered as an LSP workspace folder, enabling language servers to discover
+# symbols and references across package boundaries.
+# Currently supported for: TypeScript.
+# Example:
+# additional_workspace_folders:
+# - ../sibling-package
+# - ../shared-lib
+additional_workspace_folders: []
diff --git a/agentic/commands/audit/plausible-auditor.md b/agentic/commands/audit/plausible-auditor.md
index 6378548bb1..4e6c4eeb0b 100644
--- a/agentic/commands/audit/plausible-auditor.md
+++ b/agentic/commands/audit/plausible-auditor.md
@@ -4,13 +4,41 @@ You are the **plausible-auditor** on the audit team. Your scope is the **live Pl
## Read-only is absolute
-You may only issue HTTP `GET` requests against `https://plausible.io/api/v1/stats/*`. Forbidden: any other Plausible endpoint, any non-`GET` method, any write/mutation, any administration call. If you're unsure whether an endpoint is read-only, do not call it. (Stats API is documented at https://plausible.io/docs/stats-api.)
+You may only call the documented Plausible **Stats** APIs:
+
+- **Stats v1** (`GET https://plausible.io/api/v1/stats/{aggregate|timeseries|breakdown|realtime/visitors}`) — handy for one-shot lookups (top events, top pages, single metric over a range).
+- **Stats v2 query** (`POST https://plausible.io/api/v2/query`) — the same endpoint the backend uses for `/insights/visitors`. POST is required because the query DSL lives in the JSON body; the call is still read-only (no mutation). See `docs/reference/plausible.md` for the canonical request shape used in the codebase.
+
+Forbidden: every other Plausible endpoint (sites, goals, custom-props, shared-links, anything under `/api/v1/sites/*` or admin), any DELETE/PUT/PATCH, any call that creates or changes state. If you're unsure whether an endpoint is read-only, do not call it. (Stats API docs: https://plausible.io/docs/stats-api and https://plausible.io/docs/stats-api-v2.)
## Auth contract — never block the run
-1. First step: read `PLAUSIBLE_API_KEY` from the environment.
-2. If unset/empty: send `COVERAGE: blocked`, single `LIMITATION: PLAUSIBLE_API_KEY env var not set` line, return zero findings.
-3. Otherwise proceed. Use the key as `Authorization: Bearer $PLAUSIBLE_API_KEY` in every request. Never log or echo the key value.
+The key was provisioned in 2026-05 and lives in **three** places — try them in order so an unset shell env doesn't immediately block the audit:
+
+1. **Env var** — `$PLAUSIBLE_API_KEY` (CI, ad-hoc shells).
+2. **Local `.env`** — `grep -E '^PLAUSIBLE_API_KEY=' .env | cut -d= -f2-` from the repo root. The dev box has it; the file is gitignored, so this only works locally.
+3. **GCP Secret Manager** — `gcloud secrets versions access latest --secret=PLAUSIBLE_API_KEY --project=anyplot`. Requires gcloud auth on the `anyplot` project (same pattern as `ADMIN_TOKEN` / `DATABASE_URL`).
+
+If none of (1)–(3) yields a value: send `COVERAGE: blocked`, a single `LIMITATION: PLAUSIBLE_API_KEY not available via env, .env, or Secret Manager` line, return zero findings.
+
+Otherwise proceed. Use the key as `Authorization: Bearer $PLAUSIBLE_API_KEY` in every request. Never log, echo, paste, or include the key value in findings or chat output — if you need to show a curl, redact it to `Authorization: Bearer ***`.
+
+### Quick connectivity check (run before the real queries)
+
+A 1-call sanity check before spending the rest of the budget. Both forms work; pick whichever fits the next finding you're investigating:
+
+```bash
+# v1 — simplest "is the key alive?"
+curl -fsS -H "Authorization: Bearer $PLAUSIBLE_API_KEY" \
+ "https://plausible.io/api/v1/stats/aggregate?site_id=anyplot.ai&period=7d&metrics=visitors,pageviews"
+
+# v2 — same auth, POST body; mirrors api/routers/insights.py:_fetch_plausible_visitors
+curl -fsS -X POST -H "Authorization: Bearer $PLAUSIBLE_API_KEY" -H "Content-Type: application/json" \
+ -d '{"site_id":"anyplot.ai","metrics":["visitors"],"date_range":"7d","dimensions":["time:day"]}' \
+ https://plausible.io/api/v2/query
+```
+
+A 2xx with non-empty JSON means the key is live and the site_id is correct. Anything else → mark `COVERAGE: partial` and explain.
## Scope ideas (not a checklist — use judgment)
diff --git a/api/cloudbuild.yaml b/api/cloudbuild.yaml
index 2b7c8fadd7..0b1cceb60b 100644
--- a/api/cloudbuild.yaml
+++ b/api/cloudbuild.yaml
@@ -64,7 +64,11 @@ steps:
- "--port=8000"
- "--allow-unauthenticated"
- "--add-cloudsql-instances=anyplot:europe-west4:anyplot-db"
- - "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest"
+ # 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"
- "--execution-environment=gen2"
# ^|^ alt delimiter: values contain @ (emails) and may contain , (multi-email lists)
- "--set-env-vars=^|^ENVIRONMENT=production|GOOGLE_CLOUD_PROJECT=$PROJECT_ID|GCS_BUCKET=anyplot-images|CF_ACCESS_TEAM_DOMAIN=${_CF_ACCESS_TEAM_DOMAIN}|CF_ACCESS_AUD=${_CF_ACCESS_AUD}|ADMIN_ALLOWED_EMAILS=${_ADMIN_ALLOWED_EMAILS}"
diff --git a/api/routers/insights.py b/api/routers/insights.py
index e7c502b5b1..cfdeb840f3 100644
--- a/api/routers/insights.py
+++ b/api/routers/insights.py
@@ -10,22 +10,28 @@
from __future__ import annotations
import hashlib
+import logging
import random
from collections import Counter, defaultdict
-from datetime import date, datetime, timezone
+from datetime import date, datetime, timedelta, timezone
+import httpx
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from api.cache import cache_key, get_or_set_cache
from api.dependencies import require_db
+from core.config import settings
from core.constants import SUPPORTED_LIBRARIES
from core.database import ImplRepository, Spec, SpecRepository
from core.database.connection import get_db_context
from core.utils import strip_noqa_comments
+logger = logging.getLogger(__name__)
+
+
router = APIRouter(prefix="/insights", tags=["insights"])
@@ -82,6 +88,31 @@ class TimelinePoint(BaseModel):
count: int
+class DailyImplPoint(BaseModel):
+ """Implementation updates on a single day (last-30-days timeline).
+
+ Distinct from `debug.DailyImplPoint` (which uses `impls_updated`) — kept
+ separate because the public dashboard's consumer is the stats page bundle,
+ not the admin debug view, and `count` is consistent with `TimelinePoint`.
+ """
+
+ date: str # ISO "YYYY-MM-DD"
+ count: int
+
+
+class VisitorPoint(BaseModel):
+ """Unique visitors on a single day, sourced from Plausible."""
+
+ date: str # ISO "YYYY-MM-DD"
+ visitors: int
+
+
+class VisitorsResponse(BaseModel):
+ """Unique visitors per day for the public stats page."""
+
+ points: list[VisitorPoint]
+
+
class DashboardResponse(BaseModel):
"""Full dashboard statistics."""
@@ -98,6 +129,7 @@ class DashboardResponse(BaseModel):
tag_distribution: dict[str, dict[str, int]]
score_distribution: dict[str, int]
timeline: list[TimelinePoint]
+ daily_impls: list[DailyImplPoint] # Last 28 days, zero-filled
class PlotOfTheDayResponse(BaseModel):
@@ -232,6 +264,7 @@ async def _build_dashboard(repo: SpecRepository, impl_repo: ImplRepository) -> D
tag_counter: dict[str, Counter[str]] = defaultdict(Counter)
monthly_counts: Counter[str] = Counter()
+ daily_counts: Counter[str] = Counter()
score_buckets: Counter[str] = Counter()
coverage_rows: list[CoverageRow] = []
@@ -272,6 +305,13 @@ async def _build_dashboard(repo: SpecRepository, impl_repo: ImplRepository) -> D
if gen_dt:
monthly_counts[gen_dt.strftime("%Y-%m")] += 1
+ # Daily-impls timeline matches the debug page: count by `updated`
+ # so any re-generation/quality re-review of an existing impl is
+ # reflected on the day it actually moved, not on its first build.
+ upd_dt = impl.updated
+ if upd_dt:
+ daily_counts[upd_dt.date().isoformat()] += 1
+
coverage_rows.append(CoverageRow(spec_id=spec.id, title=spec.title, libraries=row_libs))
# Tag distribution (spec-level + impl-level)
@@ -321,6 +361,17 @@ async def _build_dashboard(repo: SpecRepository, impl_repo: ImplRepository) -> D
# Timeline sorted by month
timeline = [TimelinePoint(month=m, count=c) for m, c in sorted(monthly_counts.items())]
+ # Daily impls for the last 28 days, zero-filled. Window matches the
+ # visitors chart on the stats page so the two strips read side-by-side.
+ today = datetime.now(timezone.utc).date()
+ daily_impls = [
+ DailyImplPoint(
+ date=(today - timedelta(days=offset)).isoformat(),
+ count=daily_counts.get((today - timedelta(days=offset)).isoformat(), 0),
+ )
+ for offset in range(27, -1, -1)
+ ]
+
# Coverage
coverage = (total_impls / (len(all_specs) * len(SUPPORTED_LIBRARIES)) * 100) if all_specs else 0
@@ -340,6 +391,7 @@ async def _build_dashboard(repo: SpecRepository, impl_repo: ImplRepository) -> D
tag_distribution={cat: dict(counter.most_common(20)) for cat, counter in sorted(tag_counter.items())},
score_distribution=score_dist,
timeline=timeline,
+ daily_impls=daily_impls,
)
@@ -567,3 +619,86 @@ async def _fetch() -> RelatedSpecsResponse:
return await _build_related(repo, spec_id, limit, mode, library)
return await get_or_set_cache(cache_key("insights", "related", spec_id, str(limit), mode, library or ""), _fetch)
+
+
+# =============================================================================
+# 4. Plausible Visitors
+# =============================================================================
+
+
+async def _fetch_plausible_visitors() -> VisitorsResponse:
+ """Query the Plausible Stats API v2 for unique visitors per day (last 28d).
+
+ The 28-day window matches Plausible's own default "Last 28 days" report so
+ the totals here align with what the dashboard at plausible.io/anyplot.ai
+ shows by default.
+
+ Returns an empty `points` list when the API key is not configured or the
+ upstream call fails — the stats page treats `points: []` as "no data" and
+ renders a placeholder instead of an all-zero chart, so we can distinguish
+ real zeros (Plausible returned 0 visitors for a day) from missing data.
+ On a successful fetch the response is zero-filled across all 28 days so
+ the chart has a stable 28-bar width even for days Plausible has no row for.
+ """
+ if not settings.plausible_api_key:
+ return VisitorsResponse(points=[])
+
+ payload = {
+ "site_id": settings.plausible_site_id,
+ "metrics": ["visitors"],
+ "date_range": "28d",
+ "dimensions": ["time:day"],
+ }
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(
+ settings.plausible_api_url,
+ headers={"Authorization": f"Bearer {settings.plausible_api_key}", "Content-Type": "application/json"},
+ json=payload,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception:
+ logger.warning("Plausible visitors fetch failed (returning empty series)", exc_info=True)
+ return VisitorsResponse(points=[])
+
+ # Plausible v2 response: {"results": [{"dimensions": ["YYYY-MM-DD"], "metrics": [N]}, ...]}
+ by_date: dict[str, int] = {}
+ for row in data.get("results", []):
+ dims = row.get("dimensions") or []
+ metrics = row.get("metrics") or []
+ if not dims or not metrics:
+ continue
+ # The time:day dimension can come back as "YYYY-MM-DD" or an ISO datetime —
+ # normalize to a plain ISO date so the join with the zero-filled axis works.
+ day_str = str(dims[0])[:10]
+ try:
+ count = int(metrics[0] or 0)
+ except (TypeError, ValueError):
+ count = 0
+ by_date[day_str] = count
+
+ today = datetime.now(timezone.utc).date()
+ points = [
+ VisitorPoint(
+ date=(today - timedelta(days=offset)).isoformat(),
+ visitors=by_date.get((today - timedelta(days=offset)).isoformat(), 0),
+ )
+ for offset in range(27, -1, -1)
+ ]
+ return VisitorsResponse(points=points)
+
+
+@router.get("/visitors", response_model=VisitorsResponse)
+async def get_visitors() -> VisitorsResponse:
+ """Get unique visitors per day for the last 28 days (Plausible Stats API).
+
+ Cached for ~1h via stale-while-revalidate so we stay well under Plausible's
+ 600-req/h rate limit even under traffic spikes on the public stats page.
+ """
+ return await get_or_set_cache(
+ cache_key("insights", "visitors", "28d"),
+ _fetch_plausible_visitors,
+ refresh_after=3600,
+ refresh_factory=_fetch_plausible_visitors,
+ )
diff --git a/app/src/components/SectionHeader.tsx b/app/src/components/SectionHeader.tsx
index f45cb2aed5..c6f2812473 100644
--- a/app/src/components/SectionHeader.tsx
+++ b/app/src/components/SectionHeader.tsx
@@ -8,13 +8,26 @@ interface SectionHeaderProps {
prompt?: string;
title: React.ReactNode;
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;
}
const titleFontSize = { xs: '1.5rem', sm: '1.875rem', md: 'clamp(1.875rem, 3.5vw, 2.5rem)' };
-export function SectionHeader({ prompt, title, linkText, linkTo }: SectionHeaderProps) {
+export function SectionHeader({ prompt, title, linkText, linkTo, linkHref }: SectionHeaderProps) {
const { trackEvent } = useAnalytics();
+ const linkSx = {
+ fontFamily: typography.mono,
+ fontSize: '12px',
+ color: 'var(--ink-soft)',
+ textDecoration: 'none',
+ display: 'inline-flex',
+ alignItems: 'center',
+ transition: 'color 0.2s',
+ '&:hover': { color: colors.primary },
+ } as const;
return (
trackEvent('nav_click', { source: 'section_header', target: linkTo })}
- sx={{
- fontFamily: typography.mono,
- fontSize: '12px',
- color: 'var(--ink-soft)',
- textDecoration: 'none',
- display: 'inline-flex',
- alignItems: 'center',
- transition: 'color 0.2s',
- '&:hover': { color: colors.primary },
- }}
+ sx={linkSx}
+ >
+ {linkText}
+
+ )}
+ {linkText && linkHref && !linkTo && (
+ trackEvent('external_link', { source: 'section_header', destination: linkHref })}
+ sx={linkSx}
>
{linkText}
diff --git a/app/src/pages/StatsPage.test.tsx b/app/src/pages/StatsPage.test.tsx
index 30a580161a..871109ebc1 100644
--- a/app/src/pages/StatsPage.test.tsx
+++ b/app/src/pages/StatsPage.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '../test-utils';
import { StatsPage } from './StatsPage';
@@ -62,24 +62,55 @@ const mockDashboard = {
{ month: '2025-01', count: 10 },
{ month: '2025-02', count: 20 },
],
+ daily_impls: [
+ { date: '2026-04-14', count: 3 },
+ { date: '2026-04-15', count: 5 },
+ { date: '2026-04-16', count: 0 },
+ ],
+};
+
+const mockVisitors = {
+ points: [
+ { date: '2026-04-14', visitors: 12 },
+ { date: '2026-04-15', visitors: 25 },
+ { date: '2026-04-16', visitors: 7 },
+ ],
};
-function mockFetchSuccess() {
+/**
+ * StatsPage performs two independent fetches: /insights/dashboard and
+ * /insights/visitors. Route by URL so the visitors response isn't silently
+ * replaced by the dashboard payload (and vice versa).
+ */
+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),
+ });
}),
);
}
function mockFetchError() {
+ // /insights/dashboard fails; visitors fetch can succeed-with-empty so the
+ // visitors `useEffect` resolves cleanly and the test asserts on the
+ // dashboard error path only.
vi.stubGlobal(
'fetch',
- vi.fn().mockResolvedValue({
- ok: false,
- status: 500,
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes('/insights/visitors')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ points: [] }) });
+ }
+ return Promise.resolve({ ok: false, status: 500 });
}),
);
}
@@ -89,6 +120,12 @@ describe('StatsPage', () => {
vi.restoreAllMocks();
});
+ // vi.restoreAllMocks() doesn't undo vi.stubGlobal — without this hook the
+ // mocked `fetch` would leak into other test suites in the same worker.
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
it('renders loading state initially', () => {
// fetch never resolves so component stays in loading
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
@@ -184,4 +221,30 @@ describe('StatsPage', () => {
expect(screen.getByText('timeline')).toBeInTheDocument();
});
});
+
+ it('renders visitors section header, chart, and Plausible link when Plausible returns data', async () => {
+ mockFetchSuccess();
+
+ render();
+
+ await waitFor(() => {
+ // 12 + 25 + 7 = 44 total
+ expect(screen.getByText(/unique visitors · last 28 days · 44 total/)).toBeInTheDocument();
+ });
+ // section header matches the libraries/timeline pattern
+ expect(screen.getByText('visitors')).toBeInTheDocument();
+ // link makes the destination explicit, styled as a code-call to match the
+ // site's terminal/monospace aesthetic
+ expect(screen.getByText('plausible.view()')).toBeInTheDocument();
+ });
+
+ it('renders the "visitor data unavailable" placeholder when Plausible returns empty points', async () => {
+ mockFetchSuccess({ points: [] });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/visitor data unavailable/)).toBeInTheDocument();
+ });
+ });
});
diff --git a/app/src/pages/StatsPage.tsx b/app/src/pages/StatsPage.tsx
index c33e39498d..619df4c106 100644
--- a/app/src/pages/StatsPage.tsx
+++ b/app/src/pages/StatsPage.tsx
@@ -59,6 +59,20 @@ interface TimelinePoint {
count: number;
}
+interface DailyImplPoint {
+ date: string; // ISO YYYY-MM-DD
+ count: number;
+}
+
+interface VisitorPoint {
+ date: string; // ISO YYYY-MM-DD
+ visitors: number;
+}
+
+interface VisitorsResponse {
+ points: VisitorPoint[];
+}
+
interface DashboardData {
total_specs: number;
total_implementations: number;
@@ -72,6 +86,7 @@ interface DashboardData {
tag_distribution: Record>;
score_distribution: Record;
timeline: TimelinePoint[];
+ daily_impls: DailyImplPoint[];
}
function scoreColor(score: number | null): string {
@@ -92,6 +107,7 @@ export function StatsPage() {
const { trackPageview, trackEvent } = useAnalytics();
const { isDark } = useTheme();
const [data, setData] = useState(null);
+ const [visitors, setVisitors] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -107,6 +123,15 @@ export function StatsPage() {
.finally(() => setLoading(false));
}, []);
+ // Visitors load separately so a Plausible outage / missing API key never
+ // blocks the rest of the dashboard from rendering.
+ useEffect(() => {
+ fetch(`${API_URL}/insights/visitors`)
+ .then(r => (r.ok ? r.json() : null))
+ .then((res: VisitorsResponse | null) => setVisitors(res?.points ?? []))
+ .catch(() => setVisitors([]));
+ }, []);
+
if (loading) return (
loading stats...
@@ -119,7 +144,12 @@ export function StatsPage() {
);
- const maxTimeline = Math.max(...data.timeline.map(t => t.count), 1);
+ const dailyImpls = data.daily_impls ?? [];
+ const maxDaily = Math.max(...dailyImpls.map(d => d.count), 1);
+ const visitorPoints = visitors ?? [];
+ const maxVisitors = Math.max(...visitorPoints.map(v => v.visitors), 1);
+ const totalVisitors = visitorPoints.reduce((sum, v) => sum + v.visitors, 0);
+ const totalDailyImpls = dailyImpls.reduce((sum, d) => sum + d.count, 0);
return (
<>
@@ -150,13 +180,64 @@ export function StatsPage() {
))}
-
- visitor analytics at{' '}
- trackEvent('external_link', { destination: 'plausible' })}
- sx={{ color: semanticColors.mutedText, textDecoration: 'none', '&:hover': { color: colors.primaryDark } }}
- >plausible.io/anyplot.ai
-
+ {/* Unique visitors (last 28 days) — sourced from the Plausible Stats API.
+ The 28-day window matches Plausible's own default "Last 28 days"
+ report so totals here align with the dashboard at plausible.io/anyplot.ai.
+ `plausible.view()` lives in the section-header link slot, matching
+ the `libraries.all()` / `map.explore()` style used across the site. */}
+
+ visitors}
+ linkText="plausible.view()"
+ linkHref="https://plausible.io/anyplot.ai"
+ />
+
+
+
+ unique visitors · last 28 days{visitors !== null && visitorPoints.length > 0 ? ` · ${formatNum(totalVisitors)} total` : ''}
+
+ {visitors === null ? (
+
+
+ loading visitor data...
+
+
+ ) : visitorPoints.length === 0 ? (
+
+
+ visitor data unavailable — see plausible.io/anyplot.ai
+
+
+ ) : (
+ <>
+
+ {visitorPoints.map(point => (
+
+
+
+ ))}
+
+
+
+ {visitorPoints[0]?.date}
+
+
+ {visitorPoints[visitorPoints.length - 1]?.date}
+
+
+ >
+ )}
+
{/* Library Stats — dual mini histograms per library */}
@@ -273,18 +354,23 @@ export function StatsPage() {
more
- {/* Timeline */}
- {data.timeline.length > 0 && (
+ {/* Timeline — daily implementation updates over the last 28 days.
+ Window matches the visitors chart above so the two bar strips
+ read side-by-side. */}
+ {dailyImpls.length > 0 && (
<>
timeline} />
+
+ implementations updated · last 28 days · {totalDailyImpls} total
+
- {data.timeline.slice(-24).map(point => (
-
+ {dailyImpls.map(point => (
+
- {data.timeline.slice(-24)[0]?.month ?? data.timeline[0]?.month}
+ {dailyImpls[0]?.date}
- {data.timeline[data.timeline.length - 1]?.month}
+ {dailyImpls[dailyImpls.length - 1]?.date}
>
diff --git a/core/config.py b/core/config.py
index 3f85f6c3f5..d70f0d97d0 100644
--- a/core/config.py
+++ b/core/config.py
@@ -192,6 +192,22 @@ def _parse_admin_allowed_emails(cls, value: Any) -> Any:
return [item.strip() for item in stripped.split(",") if item.strip()]
return value
+ # =============================================================================
+ # PLAUSIBLE STATS API
+ # =============================================================================
+
+ 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 (or
+ when the upstream Plausible call fails) the endpoint returns `points: []`,
+ which the frontend renders as a "visitor data unavailable" placeholder."""
+
+ plausible_site_id: str = "anyplot.ai"
+ """Plausible site_id (the tracked domain) used as the query target."""
+
+ plausible_api_url: str = "https://plausible.io/api/v2/query"
+ """Plausible Stats API v2 query endpoint."""
+
# =============================================================================
# CORS
# =============================================================================
diff --git a/docs/reference/plausible.md b/docs/reference/plausible.md
index 31eb833d3e..1ca42676eb 100644
--- a/docs/reference/plausible.md
+++ b/docs/reference/plausible.md
@@ -576,6 +576,30 @@ poor # Exceeds poor threshold
---
+## Reading Stats — Backend → Plausible Stats API
+
+In addition to *sending* events, the backend reads aggregate visitor data
+from Plausible to render the unique-visitors chart at the top of the
+public stats page.
+
+- **Endpoint**: `GET /insights/visitors` (in `api/routers/insights.py`)
+- **Upstream call**: `POST https://plausible.io/api/v2/query` with body
+ `{"site_id": "anyplot.ai", "metrics": ["visitors"], "date_range": "28d", "dimensions": ["time:day"]}`
+ (28d matches Plausible's own default "Last 28 days" report so totals here
+ align with the dashboard at plausible.io/anyplot.ai)
+- **Auth**: `Authorization: Bearer ${PLAUSIBLE_API_KEY}` (Stats API key
+ created in Plausible → Account Settings → API Keys, then mirrored to
+ GCP Secret Manager as `PLAUSIBLE_API_KEY` for Cloud Run)
+- **Caching**: 1h stale-while-revalidate via `get_or_set_cache` so
+ traffic spikes stay well under Plausible's 600-req/h rate limit.
+- **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.
+
## Code Locations
- **Plausible setup**: `app/index.html` (lines 59-68)
@@ -583,6 +607,7 @@ poor # Exceeds poor threshold
- **Pageview building**: `buildPlausibleUrl()` in useAnalytics.ts
- **Core Web Vitals**: `app/src/analytics/reportWebVitals.ts`
- **Event tracking**: Passed via `onTrackEvent` prop throughout component tree
+- **Stats API consumer**: `_fetch_plausible_visitors()` in `api/routers/insights.py`
## Testing
diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py
index e79d44a5e2..dec536c712 100644
--- a/tests/unit/api/test_routers.py
+++ b/tests/unit/api/test_routers.py
@@ -1754,6 +1754,97 @@ def test_related_full_mode_with_library(self, client: TestClient) -> None:
assert len(data["related"]) == 1
assert "annotations" in data["related"][0]["shared_tags"]
+ def test_visitors_no_api_key(self, client: TestClient) -> None:
+ """Visitors endpoint returns an empty points list when no Plausible key is set,
+ so the frontend can show the "visitor data unavailable" placeholder."""
+ with (
+ patch("api.routers.insights.get_or_set_cache", side_effect=_passthrough_cache),
+ patch("api.routers.insights.settings") as mock_settings,
+ ):
+ mock_settings.plausible_api_key = None
+ response = client.get("/insights/visitors")
+ assert response.status_code == 200
+ assert response.json()["points"] == []
+
+ def test_visitors_upstream_failure_returns_empty(self, client: TestClient) -> None:
+ """An upstream Plausible failure should degrade to empty points, not zeros."""
+
+ class _RaisingClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args):
+ return False
+
+ async def post(self, *_args, **_kwargs):
+ raise RuntimeError("plausible down")
+
+ with (
+ patch("api.routers.insights.get_or_set_cache", side_effect=_passthrough_cache),
+ patch("api.routers.insights.settings") as mock_settings,
+ patch("api.routers.insights.httpx.AsyncClient", return_value=_RaisingClient()),
+ ):
+ mock_settings.plausible_api_key = "test-key"
+ mock_settings.plausible_site_id = "anyplot.ai"
+ mock_settings.plausible_api_url = "https://plausible.io/api/v2/query"
+ response = client.get("/insights/visitors")
+ assert response.status_code == 200
+ assert response.json()["points"] == []
+
+ def test_visitors_parses_plausible_response(self, client: TestClient) -> None:
+ """Visitor counts from Plausible should be merged into the zero-filled 28-day series.
+
+ Time is frozen via a patched `datetime` in the module under test so the
+ "today" the endpoint computes for zero-filling matches the "today" the
+ fake Plausible response references, regardless of when the test runs
+ (UTC midnight was the flakiness risk).
+ """
+ from datetime import datetime as _dt
+ from datetime import timezone as _tz
+
+ frozen_now = _dt(2026, 5, 13, 12, 0, 0, tzinfo=_tz.utc)
+ today_iso = frozen_now.date().isoformat()
+
+ class _MockResp:
+ status_code = 200
+
+ def raise_for_status(self) -> None:
+ return None
+
+ def json(self) -> dict:
+ return {"results": [{"dimensions": [today_iso], "metrics": [42]}]}
+
+ class _MockClient:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args):
+ return False
+
+ async def post(self, *_args, **_kwargs):
+ return _MockResp()
+
+ class _FrozenDatetime(_dt):
+ @classmethod
+ def now(cls, tz=None): # type: ignore[override]
+ return frozen_now if tz is _tz.utc else frozen_now.replace(tzinfo=None)
+
+ with (
+ patch("api.routers.insights.get_or_set_cache", side_effect=_passthrough_cache),
+ patch("api.routers.insights.settings") as mock_settings,
+ patch("api.routers.insights.httpx.AsyncClient", return_value=_MockClient()),
+ patch("api.routers.insights.datetime", _FrozenDatetime),
+ ):
+ mock_settings.plausible_api_key = "test-key"
+ mock_settings.plausible_site_id = "anyplot.ai"
+ mock_settings.plausible_api_url = "https://plausible.io/api/v2/query"
+ response = client.get("/insights/visitors")
+ assert response.status_code == 200
+ points = response.json()["points"]
+ assert len(points) == 28
+ today_point = next(p for p in points if p["date"] == today_iso)
+ assert today_point["visitors"] == 42
+
class TestSpecCodeEndpoint:
"""Tests for the /specs/{spec_id}/{library}/code endpoint."""