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."""