Skip to content

Per-graph timeframe tabs on project stats - #222

Merged
ralyodio merged 1 commit into
masterfrom
worktree-stats-timeframe-tabs
Aug 30, 2026
Merged

Per-graph timeframe tabs on project stats#222
ralyodio merged 1 commit into
masterfrom
worktree-stats-timeframe-tabs

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

Every graph on /dashboard/projects/:id/stats was locked to a fixed 30-day window, so there was no way to ask whether a number had moved recently. Each card now carries its own timeframe tabs:

1H · 4H · 1D · 1W · 1M · 1Y · All

Clicking a tab re-fetches only that panel from the new GET /api/projects/:id/tracker-stats?range=&panel=, so narrowing one chart doesn't re-run the other eleven. The page still server-renders every panel at 1M, so the default view costs no extra request, and each card caches the ranges it has already fetched.

Why the new RPCs

Every existing tracker_* RPC reads a *_daily_stats rollup, whose finest resolution is one UTC calendar day — so 1H/4H/1D had no data source at all. The new tracker_recent_* functions are raw-table twins reading public.tracker_events (row-per-event, pruned at 24h), with the same result shapes and top-N truncation, windowed by minutes instead of days.

Migration 20260830120000_tracker_recent_rpcs.sql is already applied to prod (9 new functions, purely additive — no existing function was replaced), per the one-file-at-a-time MCP workflow this repo uses.

Panels that can't do sub-day

Devices / Browsers / Operating systems and Exit pages offer the rollup ranges only. The raw event row keeps a user_agent string but no parsed device_type / browser / os columns, and exit sessions key on a last_day date rather than a timestamp. They show 5 tabs instead of 7 rather than quietly answering a different question; the API rejects an unsupported range for those panels instead of silently substituting one.

Shape

lib/tracker/panels.ts holds the (project, range, panel) -> payload mapping once, shared by the server page and the route handler, so a tab switch cannot reshape a chart whose data did not change. The portfolio page (/dashboard/analytics) aggregates across projects and drives its own page-wide range control, so it passes no projectId and renders the same cards with tabs hidden.

Verification

Local tsc/vitest are unavailable in this checkout (dangling node_modules/.bin symlinks — see repo notes), so CI is the typecheck gate here.

  • esbuild bundle of both pages, the route, and all new modules: clean — every import and named export resolves.
  • 21 runtime assertions on the pure logic (bucket axis, range table, panel gating) pass.
  • Verified against prod data: the client bucket axis and tracker_recent_series agree exactly at 13 / 17 / 25 buckets for 1H / 4H / 1D.

That last check caught a real bug: sizing the axis by division alone was one bucket short, silently dropping the oldest bucket on every sub-day tab. A 60-minute window spans 13 five-minute bucket starts, not 12.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A1L1xBbJJdiXJc1HNZpqzP

Every graph on /dashboard/projects/:id/stats was locked to a fixed 30-day
window, so there was no way to ask whether a number had moved recently —
the question that prompted this was whether /login requests fell after we
started throttling, which a 30-day total averages away.

Each card now carries its own 1H / 4H / 1D / 1W / 1M / 1Y / All tab strip
and re-fetches only itself from the new
GET /api/projects/:id/tracker-stats?range=&panel=, so narrowing one chart
does not re-run the other eleven. The page still server-renders every
panel at 1M, so the default view costs no extra request, and each card
caches the ranges it has already fetched.

Sub-day windows needed a data source the existing RPCs do not have: every
tracker_* RPC reads a *_daily_stats rollup whose finest resolution is one
UTC day. The new tracker_recent_* functions are raw-table twins reading
public.tracker_events (row-per-event, pruned at 24h) with the same result
shapes and top-N truncation, windowed by minutes. Devices / browsers / OS
and exit pages have no sub-day source — the raw row keeps a user_agent
string but no parsed columns, and exit sessions key on a date — so those
four panels offer the rollup ranges only rather than quietly answering a
different question.

lib/tracker/panels.ts holds the (project, range, panel) -> payload mapping
once, shared by the server page and the route handler, so a tab switch
cannot reshape a chart whose data did not change.

Verified against prod: the client bucket axis and tracker_recent_series
agree exactly at 13 / 17 / 25 buckets for 1H / 4H / 1D. Sizing that axis
by division alone was one short and silently dropped the oldest bucket on
every sub-day tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1L1xBbJJdiXJc1HNZpqzP
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

40 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 28 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:98
MEDIUM redos-nested-quantifier lib/sp/parseHandle.ts:92
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 23ed3cd into master Aug 30, 2026
10 checks passed
@ralyodio
ralyodio deleted the worktree-stats-timeframe-tabs branch August 30, 2026 04:18
ralyodio added a commit that referenced this pull request Aug 30, 2026
)

* Fix the 1D tab showing all-time totals on rollup-only stats panels

Exit pages, devices, browsers and operating systems have no sub-day data
source, so #222 gave them the rollup ranges only — with "1D" documented to
mean today's UTC rollup rather than a rolling 24 hours.

But the 1D range object is defined with `minutes: 1440` and no `days`, since
it is a raw-event window for every other panel. resolveDays looks for `days`,
finds nothing, and falls through to tracker_first_day, which returns the
project's entire history. So those four panels answered the 1D tab with
all-time totals, and on any site younger than 30 days 1D, 1M and All were
byte-identical.

rssamplifier.com is 15 days old, which is how this surfaced: its exit-pages
card read 169,207 for /login at 1D, 1M and All alike, while the top-pages
card — raw-backed, and therefore correct — read ~4k for the same path over
the same 24 hours. Today's actual figure is 536.

A sub-day window against a day-resolution rollup is one day, so rollupDays()
collapses any raw range to 1 for these panels and passes real rollup ranges
through untouched. The 1D tooltip is relabelled from "Last 24 hours, hourly
buckets" to "Today so far, UTC day" for the same four panels, so the tab no
longer promises a rolling window it cannot serve. Panels with a raw source
are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaCMqhvNYfKtjLHmHQD1oq

* Describe an empty rollup-only panel with its own range label

EmptyRange looked the range description up in the global table, so an exit
pages / devices / browsers / OS card with no data for 1D still said "last 24
hours, hourly buckets" while its own tab tooltip said "today so far, UTC
day". Both call sites already hold the panel's range list; pass it through
and read the label from there, falling back to the global lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaCMqhvNYfKtjLHmHQD1oq

---------

Co-authored-by: Claude Opus 5 (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.

1 participant