Skip to content

Commit fee7852

Browse files
committed
fix: harden stats page rendering and chart data guards
1 parent b3b0483 commit fee7852

4 files changed

Lines changed: 170 additions & 41 deletions

File tree

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1818
- Added CSS `100dvh` (dynamic viewport height) fallback for mobile browser compatibility
1919
- Added `-webkit-overflow-scrolling: touch` for smooth iOS scrolling
2020
- No changes to desktop UI or features
21+
- Hardened public stats page rendering
22+
- Added a local error boundary so stats failures do not blank the app
23+
- Guarded growth chart against invalid data points
2124

2225
### Added
2326

files.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ React frontend application.
5757
| File | Description |
5858
|------|-------------|
5959
| `Login.tsx` | Public homepage with WorkOS AuthKit integration. Shows "Go to Dashboard" button when logged in (no auto-redirect), "Sign in" when logged out. Includes privacy messaging, "Syncs with" section showing supported CLI tools (OpenCode, Claude Code, Droid, Codex CLI, Cursor with coming soon badge), getting started section with plugin links including codex-sync (mobile-visible), tan mode theme support with footer (theme switcher, Terms/Privacy links), footer icons (GitHub, Discord, Support, Discussions), updated mockup with view tabs and OC/CC source badges (desktop-only), feature list with Sync/Search/Private/Tag/Export/Delete keywords and eval datasets tagline, Watch the demo link, trust message with cloud/local deployment info, real-time Platform Stats leaderboard (Top Models, Top CLI) above Open Source footer link |
60-
| `Stats.tsx` | Public stats page (/stats) with platform statistics isolated from homepage. Includes MessageMilestoneCounter (real-time message count with dynamic milestones, progress bar), AnimatedGrowthChart (SVG chart with play/reset animation showing cumulative message growth over days, dynamic Y-axis scaling, X-axis date labels). Theme toggle, back link to homepage. No auth required. |
60+
| `Stats.tsx` | Public stats page (/stats) with platform statistics isolated from homepage. Includes MessageMilestoneCounter (real-time message count with dynamic milestones, progress bar), AnimatedGrowthChart (SVG chart with play/reset animation showing cumulative message growth over days, dynamic Y-axis scaling, X-axis date labels), plus a local error boundary and data guards so chart failures do not blank the app. Theme toggle, back link to homepage. No auth required. |
6161
| `Dashboard.tsx` | Main dashboard with custom themed source filter dropdown (filters by user's enabled agents from Settings, dark/tan mode support, click-outside close, escape key close), source badges (CC/OC/FD), eval toggle button, Context link with search icon, setup banner for new users with 3-column plugin cards (OpenCode, Claude Code, Factory Droid), mobile-optimized header/filters/session rows, URL param support for deep linking from Context search (?session=id), Cmd/Ctrl+K shortcut to open Context search, MessageBubble with content normalization helpers (getPartTextContent, getToolName) for multi-plugin format support, flash-free session transitions using lastValidSessionRef cache (shows cached content until new data loads, subtle corner spinner), and five views: Overview (responsive stat grids), Sessions (mobile-friendly list with stacked layout), Evals (eval-ready sessions with export modal), Analytics (responsive breakdowns with collapsible filters), Wrapped (Daily Sync Wrapped visualization) |
6262
| `Settings.tsx` | Tabbed settings: API Access (two-column layout with Plugin Setup and AI Coding Agents sections, keys, endpoints with plugin links including codex-sync), Profile (collapsible section for privacy, account info, Legal section with Terms/Privacy links, Danger Zone with delete data/account options). AI Coding Agents section lets users enable/disable CLI tools for the source filter dropdown (OpenCode, Claude Code, Factory Droid, Codex CLI with supported status, Cursor, Continue, Amp, Aider, Goose, Mentat, Cline, Kilo Code). Back link navigates to /dashboard |
6363
| `Docs.tsx` | Comprehensive documentation page with instant typeahead search (Cmd/Ctrl+K shortcut), left sidebar navigation (hidden scrollbar) with npm links (opencode-sync-plugin, claude-code-sync, codex-sync), right table of contents, anchor tags, copy/view as markdown buttons, mobile responsive, works with both dark/tan themes. Search indexes all sections with keywords for quick navigation to any topic via hash anchor. Covers use hosted version (with features, 3-plugin install cards with OC/CC/CX badges, login/sync for all three plugins), self-hosting requirements with cloud and 100% local deployment options, quick start (3-plugin cards), dashboard features, OpenCode plugin, Claude Code plugin, Codex CLI plugin with full documentation sections, API reference, search types, authentication, hosting, fork guide, troubleshooting, and FAQ. Links to opencode.ai, claude.ai, and openai.com/codex in hero and plugin sections. |

src/pages/Stats.tsx

Lines changed: 163 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { useState, useEffect } from "react";
1+
import { useState, useEffect, Component, type ReactNode } from "react";
22
import { useQuery } from "convex/react";
33
import { api } from "../../convex/_generated/api";
44
import { Link } from "react-router-dom";
5-
import { useTheme } from "../lib/theme";
5+
import { useTheme, type Theme } from "../lib/theme";
66
import {
77
Sun,
88
Moon,
@@ -127,9 +127,15 @@ function AnimatedGrowthChart({ isDark }: { isDark: boolean }) {
127127

128128
// Get data points - limit to last 60 days for readability
129129
const dataPoints = growthData?.slice(-60) ?? [];
130-
const maxCumulative = dataPoints.length > 0
131-
? Math.max(...dataPoints.map(d => d.cumulative))
132-
: 100;
130+
// Guard against invalid data so the chart never crashes
131+
const safePoints = dataPoints.filter((point) =>
132+
Number.isFinite(point.cumulative),
133+
);
134+
const chartPoints = safePoints.length > 0 ? safePoints : [];
135+
const maxCumulative =
136+
chartPoints.length > 0
137+
? Math.max(...chartPoints.map((point) => point.cumulative))
138+
: 100;
133139

134140
// Calculate Y-axis scale
135141
const getNiceMax = (value: number): number => {
@@ -172,23 +178,45 @@ function AnimatedGrowthChart({ isDark }: { isDark: boolean }) {
172178
const innerWidth = chartWidth - padding.left - padding.right;
173179
const innerHeight = chartHeight - padding.top - padding.bottom;
174180

175-
const pathData = dataPoints.length >= 1
176-
? dataPoints.map((d, i) => {
177-
const x = padding.left + (dataPoints.length === 1 ? innerWidth : (i / (dataPoints.length - 1)) * innerWidth);
178-
const y = padding.top + innerHeight - (d.cumulative / yAxisMax) * innerHeight;
179-
return i === 0 ? `M ${padding.left} ${padding.top + innerHeight} L ${x} ${y}` : `L ${x} ${y}`;
180-
}).join(' ')
181-
: '';
182-
183-
const areaPath = pathData && dataPoints.length >= 1
184-
? `M ${padding.left} ${padding.top + innerHeight} ` +
185-
dataPoints.map((d, i) => {
186-
const x = padding.left + (dataPoints.length === 1 ? innerWidth : (i / (dataPoints.length - 1)) * innerWidth);
187-
const y = padding.top + innerHeight - (d.cumulative / yAxisMax) * innerHeight;
188-
return `L ${x} ${y}`;
189-
}).join(' ') +
190-
` L ${padding.left + (dataPoints.length === 1 ? innerWidth : innerWidth)} ${padding.top + innerHeight} Z`
191-
: '';
181+
const pathData =
182+
chartPoints.length >= 1
183+
? chartPoints
184+
.map((point, i) => {
185+
const x =
186+
padding.left +
187+
(chartPoints.length === 1
188+
? innerWidth
189+
: (i / (chartPoints.length - 1)) * innerWidth);
190+
const y =
191+
padding.top +
192+
innerHeight -
193+
(point.cumulative / yAxisMax) * innerHeight;
194+
return i === 0
195+
? `M ${padding.left} ${padding.top + innerHeight} L ${x} ${y}`
196+
: `L ${x} ${y}`;
197+
})
198+
.join(" ")
199+
: "";
200+
201+
const areaPath =
202+
pathData && chartPoints.length >= 1
203+
? `M ${padding.left} ${padding.top + innerHeight} ` +
204+
chartPoints
205+
.map((point, i) => {
206+
const x =
207+
padding.left +
208+
(chartPoints.length === 1
209+
? innerWidth
210+
: (i / (chartPoints.length - 1)) * innerWidth);
211+
const y =
212+
padding.top +
213+
innerHeight -
214+
(point.cumulative / yAxisMax) * innerHeight;
215+
return `L ${x} ${y}`;
216+
})
217+
.join(" ") +
218+
` L ${padding.left + innerWidth} ${padding.top + innerHeight} Z`
219+
: "";
192220

193221
const formatDateLabel = (dateStr: string) => {
194222
try {
@@ -200,17 +228,23 @@ function AnimatedGrowthChart({ isDark }: { isDark: boolean }) {
200228
}
201229
};
202230

203-
const xLabels = dataPoints.length >= 1
204-
? dataPoints.length === 1
205-
? [formatDateLabel(dataPoints[0].date)]
206-
: dataPoints.length === 2
207-
? [formatDateLabel(dataPoints[0].date), formatDateLabel(dataPoints[1].date)]
208-
: [
209-
formatDateLabel(dataPoints[0].date),
210-
formatDateLabel(dataPoints[Math.floor(dataPoints.length / 2)].date),
211-
formatDateLabel(dataPoints[dataPoints.length - 1].date),
212-
]
213-
: [];
231+
const xLabels =
232+
chartPoints.length >= 1
233+
? chartPoints.length === 1
234+
? [formatDateLabel(chartPoints[0].date)]
235+
: chartPoints.length === 2
236+
? [
237+
formatDateLabel(chartPoints[0].date),
238+
formatDateLabel(chartPoints[1].date),
239+
]
240+
: [
241+
formatDateLabel(chartPoints[0].date),
242+
formatDateLabel(
243+
chartPoints[Math.floor(chartPoints.length / 2)].date,
244+
),
245+
formatDateLabel(chartPoints[chartPoints.length - 1].date),
246+
]
247+
: [];
214248

215249
return (
216250
<div
@@ -263,7 +297,7 @@ function AnimatedGrowthChart({ isDark }: { isDark: boolean }) {
263297
</div>
264298

265299
<div className="relative" style={{ height: chartHeight + 20 }}>
266-
{dataPoints.length === 0 ? (
300+
{chartPoints.length === 0 ? (
267301
<div
268302
className={`flex items-center justify-center h-full text-sm ${
269303
isDark ? "text-zinc-600" : "text-[#8b7355]"
@@ -345,10 +379,15 @@ function AnimatedGrowthChart({ isDark }: { isDark: boolean }) {
345379
clipPath={isPlaying ? `url(#clipPath-${animationKey})` : undefined}
346380
/>
347381

348-
{dataPoints.length > 0 && !isPlaying && (
382+
{chartPoints.length > 0 && !isPlaying && (
349383
<circle
350384
cx={padding.left + innerWidth}
351-
cy={padding.top + innerHeight - (dataPoints[dataPoints.length - 1].cumulative / yAxisMax) * innerHeight}
385+
cy={
386+
padding.top +
387+
innerHeight -
388+
(chartPoints[chartPoints.length - 1].cumulative / yAxisMax) *
389+
innerHeight
390+
}
352391
r="3"
353392
fill={isDark ? "#10b981" : "#059669"}
354393
/>
@@ -369,15 +408,15 @@ function AnimatedGrowthChart({ isDark }: { isDark: boolean }) {
369408
)}
370409
</div>
371410

372-
{dataPoints.length > 0 && (
411+
{chartPoints.length > 0 && (
373412
<div
374413
className={`mt-3 pt-3 border-t flex justify-between text-xs ${
375414
isDark ? "border-zinc-800 text-zinc-500" : "border-[#e6e4e1] text-[#8b7355]"
376415
}`}
377416
>
378417
<span>
379418
Total: <span className={isDark ? "text-zinc-300" : "text-[#1a1a1a]"}>
380-
{dataPoints[dataPoints.length - 1].cumulative.toLocaleString()}
419+
{chartPoints[chartPoints.length - 1].cumulative.toLocaleString()}
381420
</span>
382421
</span>
383422
<span>
@@ -390,8 +429,81 @@ function AnimatedGrowthChart({ isDark }: { isDark: boolean }) {
390429
}
391430

392431
// Main Stats page component
393-
export function StatsPage() {
394-
const { theme, setTheme } = useTheme();
432+
class StatsErrorBoundary extends Component<
433+
{ children: ReactNode; isDark: boolean },
434+
{ hasError: boolean }
435+
> {
436+
state = { hasError: false };
437+
438+
static getDerivedStateFromError() {
439+
return { hasError: true };
440+
}
441+
442+
componentDidCatch(error: unknown) {
443+
console.error("Stats page crashed", error);
444+
}
445+
446+
render() {
447+
if (!this.state.hasError) {
448+
return this.props.children;
449+
}
450+
451+
const { isDark } = this.props;
452+
return (
453+
<div
454+
className={`min-h-screen ${
455+
isDark ? "bg-[#0a0a0a] text-zinc-100" : "bg-[#f8f6f3] text-[#1a1a1a]"
456+
}`}
457+
>
458+
<div className="max-w-4xl mx-auto px-4 py-16 text-center">
459+
<h1
460+
className={`text-lg font-semibold ${
461+
isDark ? "text-zinc-100" : "text-[#1a1a1a]"
462+
}`}
463+
>
464+
Stats failed to load
465+
</h1>
466+
<p
467+
className={`mt-2 text-sm ${
468+
isDark ? "text-zinc-500" : "text-[#8b7355]"
469+
}`}
470+
>
471+
We hit an error while loading metrics. Refresh to try again.
472+
</p>
473+
<div className="mt-6 flex items-center justify-center gap-3">
474+
<Link
475+
to="/"
476+
className={`text-sm ${
477+
isDark
478+
? "text-zinc-400 hover:text-zinc-200"
479+
: "text-[#8b7355] hover:text-[#1a1a1a]"
480+
}`}
481+
>
482+
Back
483+
</Link>
484+
<button
485+
onClick={() => window.location.reload()}
486+
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
487+
isDark
488+
? "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
489+
: "bg-[#ebe9e6] text-[#1a1a1a] hover:bg-[#e6e4e1]"
490+
}`}
491+
>
492+
Reload
493+
</button>
494+
</div>
495+
</div>
496+
</div>
497+
);
498+
}
499+
}
500+
501+
type StatsPageContentProps = {
502+
theme: Theme;
503+
setTheme: (theme: Theme) => void;
504+
};
505+
506+
function StatsPageContent({ theme, setTheme }: StatsPageContentProps) {
395507
const isDark = theme === "dark";
396508

397509
return (
@@ -464,3 +576,14 @@ export function StatsPage() {
464576
</div>
465577
);
466578
}
579+
580+
export function StatsPage() {
581+
const { theme, setTheme } = useTheme();
582+
const isDark = theme === "dark";
583+
584+
return (
585+
<StatsErrorBoundary isDark={isDark}>
586+
<StatsPageContent theme={theme} setTheme={setTheme} />
587+
</StatsErrorBoundary>
588+
);
589+
}

task.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ OpenSync supports two AI coding tools: **OpenCode** and **Claude Code**.
3333
- Dark/tan theme support with toggle
3434
- Back link to homepage
3535
- No auth required (public page)
36+
- [x] Stabilized public stats page rendering
37+
- Added local error boundary to prevent full app blanking
38+
- Guarded growth chart against invalid data points
3639
- [x] Added new Convex queries in analytics.ts
3740
- publicMessageCount: returns total message documents (no auth)
3841
- publicMessageGrowth: returns daily counts with cumulative totals (no auth)

0 commit comments

Comments
 (0)