diff --git a/src/App.test.js b/src/App.test.js
index 9582a4c..f499c69 100644
--- a/src/App.test.js
+++ b/src/App.test.js
@@ -6,6 +6,12 @@ jest.mock('./app/api', () => ({
api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() },
}));
+// Make the landing page render synchronously in tests so we don't race Suspense/lazy loading.
+jest.mock('./landing/pages/Intro', () => {
+ const React = require('react');
+ return () => React.createElement('div', null, 'Sign in with Google');
+});
+
beforeAll(() => {
global.IntersectionObserver = class {
observe() {}
diff --git a/src/admin/Admin.css b/src/admin/Admin.css
index f891af7..7a01d1c 100644
--- a/src/admin/Admin.css
+++ b/src/admin/Admin.css
@@ -262,6 +262,111 @@ td.admin-total-records {
cursor: not-allowed;
}
+/* ── Tabs ── */
+.admin-tabs {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 28px;
+}
+
+.admin-tab {
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ color: #9ca3af;
+ padding: 8px 20px;
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.admin-tab--active {
+ background-color: #ffc107;
+ color: #2c3e50;
+ border-color: #ffc107;
+ font-weight: 600;
+}
+
+.admin-tab:hover:not(.admin-tab--active) {
+ border-color: #ffc107;
+ color: #ffc107;
+}
+
+/* ── Performance tab ── */
+.perf-stat-cards {
+ margin-bottom: 32px;
+}
+
+.perf-stat-unit {
+ font-size: 0.7rem;
+ color: #9ca3af;
+ margin-top: 4px;
+}
+
+.perf-stat-samples {
+ color: #6b7280;
+}
+
+.perf-good {
+ color: #22c55e !important;
+}
+
+.perf-needs-improvement {
+ color: #f59e0b !important;
+}
+
+.perf-poor {
+ color: #ef4444 !important;
+}
+
+.perf-regressions {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.perf-regression-card {
+ background-color: rgba(0, 0, 0, 0.15);
+ border-radius: 8px;
+ padding: 14px 16px;
+}
+
+.perf-regression-route {
+ font-size: 0.8rem;
+ color: #e5e7eb;
+ font-family: monospace;
+ word-break: break-all;
+ margin-bottom: 4px;
+}
+
+.perf-regression-metric {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #9ca3af;
+ margin-bottom: 6px;
+}
+
+.perf-regression-delta {
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin-bottom: 4px;
+}
+
+.perf-improved {
+ color: #22c55e;
+}
+
+.perf-regressed {
+ color: #ef4444;
+}
+
+.perf-regression-values {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
.admin-empty {
display: flex;
align-items: center;
@@ -351,4 +456,18 @@ td.admin-total-records {
padding: 4px 10px;
font-size: 0.9rem;
}
+
+ .admin-tabs {
+ gap: 6px;
+ margin-bottom: 20px;
+ }
+
+ .admin-tab {
+ padding: 6px 14px;
+ font-size: 0.8rem;
+ }
+
+ .perf-regressions {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/src/admin/Admin.jsx b/src/admin/Admin.jsx
index cc925a6..5e56f63 100644
--- a/src/admin/Admin.jsx
+++ b/src/admin/Admin.jsx
@@ -1,23 +1,22 @@
-import React, { useState, useEffect } from 'react';
-import { api as axios } from '../app/api';
+import React, { useState } from 'react';
import PageMeta from '../app/components/ui/PageMeta';
import { Line } from 'react-chartjs-2';
import '../app/components/stats/chartConfig';
import './Admin.css';
+import UsersTab from './UsersTab';
+import PerfTab from './PerfTab';
-const constants = require('../app/constants');
+export const LINE_COLOR = '#ffc107';
+export const GRID_COLOR = 'rgba(229, 231, 235, 0.15)';
+export const TICK_COLOR = '#e5e7eb';
-const LINE_COLOR = '#ffc107';
-const GRID_COLOR = 'rgba(229, 231, 235, 0.15)';
-const TICK_COLOR = '#e5e7eb';
-
-function buildLineData(label, entries, xKey, color) {
+export function buildLineData(label, entries, xKey, color, yKey = 'count') {
return {
labels: entries.map(e => e[xKey]),
datasets: [
{
label,
- data: entries.map(e => e.count),
+ data: entries.map(e => e[yKey]),
borderColor: color,
backgroundColor: color + '33',
tension: 0.3,
@@ -29,7 +28,7 @@ function buildLineData(label, entries, xKey, color) {
};
}
-function buildLineOptions(yTitle) {
+export function buildLineOptions(yTitle) {
return {
responsive: true,
maintainAspectRatio: false,
@@ -40,7 +39,7 @@ function buildLineOptions(yTitle) {
scales: {
y: {
beginAtZero: true,
- ticks: { color: TICK_COLOR, stepSize: 1 },
+ ticks: { color: TICK_COLOR, precision: 0 },
title: { display: true, text: yTitle, color: TICK_COLOR },
grid: { color: GRID_COLOR },
},
@@ -52,17 +51,7 @@ function buildLineOptions(yTitle) {
};
}
-function avg(arr) {
- if (!arr.length) return 0;
- return Math.round(arr.reduce((s, d) => s + d.count, 0) / arr.length);
-}
-
-function formatDate(iso) {
- if (!iso) return —;
- return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
-}
-
-function ChartBlock({ title, entries, xKey, yTitle, color }) {
+export function ChartBlock({ title, entries, xKey, yTitle, color, yKey = 'count' }) {
const [open, setOpen] = useState(true);
const hasData = entries.length > 0;
@@ -75,7 +64,7 @@ function ChartBlock({ title, entries, xKey, yTitle, color }) {
{open && (
hasData ? (
-
+
) : (
No data for this range
@@ -85,193 +74,9 @@ function ChartBlock({ title, entries, xKey, yTitle, color }) {
);
}
-function UsersTable() {
- const [page, setPage] = useState(1);
- const [sort, setSort] = useState('lastActiveAt');
- const [order, setOrder] = useState('desc');
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [usersError, setUsersError] = useState('');
-
- useEffect(() => {
- setLoading(true);
- setUsersError('');
- axios.get(
- `${constants.SERVER_URL}/api/admin/users?page=${page}&sort=${sort}&order=${order}`,
- { withCredentials: true }
- )
- .then(res => {
- if (res.data.success) {
- setData(res.data);
- } else {
- setUsersError(res.data.message || 'Failed to load users. Try again.');
- }
- })
- .catch(() => setUsersError('Failed to load users. Try again.'))
- .finally(() => setLoading(false));
- }, [page, sort, order]);
-
- const SORT_FIELDS = ['lastActiveAt', 'createdAt', 'totalRecords'];
-
- const handleSort = (field) => {
- if (!SORT_FIELDS.includes(field)) return;
- if (sort === field) {
- setOrder(o => o === 'desc' ? 'asc' : 'desc');
- } else {
- setSort(field);
- setOrder('desc');
- }
- setPage(1);
- };
-
- const sortIcon = (field) => {
- if (sort !== field) return ↕;
- return {order === 'desc' ? '↓' : '↑'};
- };
-
- return (
-
-
-
All Users
-
- {data && (
-
- Page {data.page} of {data.totalPages} ({data.total} total)
-
- )}
-
-
-
-
- {!loading && usersError && (
-
- {usersError}
-
- )}
- {loading ? (
-
- ) : (
-
-
-
-
- | # |
- Display Name |
- Email |
- handleSort('lastActiveAt')}
- >
- Last Active {sortIcon('lastActiveAt')}
- |
- handleSort('createdAt')}
- >
- Joined {sortIcon('createdAt')}
- |
- handleSort('totalRecords')}
- >
- Total Records {sortIcon('totalRecords')}
- |
-
-
-
- {data?.users.map((u, i) => (
-
- | {(data.page - 1) * 10 + i + 1} |
- {u.displayName} |
- {u.email || —} |
- {formatDate(u.lastActiveAt)} |
- {formatDate(u.createdAt)} |
- {u.totalRecords ?? 0} |
-
- ))}
-
-
-
- )}
-
- );
-}
-
const Admin = () => {
const [range, setRange] = useState(30);
- const [stats, setStats] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const fetchStats = async () => {
- setLoading(true);
- setError(null);
- try {
- const res = await axios.get(
- `${constants.SERVER_URL}/api/admin/stats?range=${range}`,
- { withCredentials: true }
- );
- if (res.data.success) {
- setStats(res.data);
- } else {
- setError('Failed to load admin stats.');
- }
- } catch (err) {
- if (err.response?.status === 403) {
- setError('Access denied.');
- } else {
- setError('Error loading admin stats.');
- }
- } finally {
- setLoading(false);
- }
- };
- fetchStats();
- }, [range]);
-
- if (loading) {
- return (
-
-
-
- Loading...
-
-
Loading dashboard...
-
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- const avgDAU = avg(stats.dailyActiveUsers);
- const avgMAU = avg(stats.monthlyActiveUsers);
- const totalSignups = stats.newSignupsPerDay.reduce((s, d) => s + d.count, 0);
+ const [activeTab, setActiveTab] = useState('users');
return (
@@ -289,50 +94,19 @@ const Admin = () => {
-
-
-
Total Users
-
{stats.totalUsers.toLocaleString()}
-
-
-
Avg Daily Active
-
{avgDAU.toLocaleString()}
-
-
-
Avg Monthly Active
-
{avgMAU.toLocaleString()}
-
-
-
New Signups (range)
-
{totalSignups.toLocaleString()}
-
+
+
+
-
-
-
-
-
-
-
+ {activeTab === 'users' &&
}
+ {activeTab === 'perf' &&
}
);
};
diff --git a/src/admin/PerfTab.jsx b/src/admin/PerfTab.jsx
new file mode 100644
index 0000000..fe6fed8
--- /dev/null
+++ b/src/admin/PerfTab.jsx
@@ -0,0 +1,217 @@
+import React, { useState, useEffect } from 'react';
+import { api as axios } from '../app/api';
+import { Bar } from 'react-chartjs-2';
+import { ChartBlock, GRID_COLOR, TICK_COLOR } from './Admin';
+
+const constants = require('../app/constants');
+
+const METRIC_THRESHOLDS = {
+ LCP: { good: 2500, poor: 4000, unit: 'ms' },
+ CLS: { good: 100, poor: 250, unit: '×10⁻³' },
+ INP: { good: 200, poor: 500, unit: 'ms' },
+ TTFB: { good: 800, poor: 1800, unit: 'ms' },
+};
+
+const TREND_COLORS = {
+ LCP: '#ffc107',
+ CLS: '#22c55e',
+ INP: '#f59e0b',
+ TTFB: '#74b9ff',
+};
+
+function ratingClass(name, value) {
+ const t = METRIC_THRESHOLDS[name];
+ if (!t) return '';
+ if (value <= t.good) return 'perf-good';
+ if (value <= t.poor) return 'perf-needs-improvement';
+ return 'perf-poor';
+}
+
+export default function PerfTab({ range }) {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ setError('');
+ axios.get(`${constants.SERVER_URL}/api/admin/perf?range=${range}`)
+ .then(res => {
+ if (res.data.success) setData(res.data);
+ else setError('Failed to load performance data.');
+ })
+ .catch(() => setError('Failed to load performance data.'))
+ .finally(() => setLoading(false));
+ }, [range]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) return {error}
;
+ if (!data) return null;
+
+ const { current, previous, trends } = data;
+
+ if (!current.length && (!trends || !trends.length)) {
+ return (
+
+ No performance data yet. Browse the app to generate metrics.
+
+ );
+ }
+
+ const globalMetrics = {};
+ current.forEach(c => {
+ if (!globalMetrics[c.name]) globalMetrics[c.name] = [];
+ globalMetrics[c.name].push(c);
+ });
+
+ const summaryCards = ['LCP', 'CLS', 'INP', 'TTFB'].map(name => {
+ const items = globalMetrics[name] || [];
+ if (!items.length) return { name, p75: null, count: 0 };
+ const allP75 = items.map(i => i.p75).filter(v => v != null);
+ allP75.sort((a, b) => a - b);
+ const median = allP75.length ? allP75[Math.floor(allP75.length / 2)] : null;
+ const count = items.reduce((s, i) => s + i.count, 0);
+ return { name, p75: median, count };
+ });
+
+ const lcpByRoute = (globalMetrics['LCP'] || [])
+ .filter(r => r.p75 != null)
+ .sort((a, b) => b.p75 - a.p75)
+ .slice(0, 5);
+
+ const slowestRoutesData = {
+ labels: lcpByRoute.map(r => r.route),
+ datasets: [{
+ label: 'p75 LCP (ms)',
+ data: lcpByRoute.map(r => Math.round(r.p75)),
+ backgroundColor: lcpByRoute.map(r => {
+ if (r.p75 <= 2500) return '#22c55e';
+ if (r.p75 <= 4000) return '#f59e0b';
+ return '#ef4444';
+ }),
+ borderRadius: 4,
+ }],
+ };
+
+ const slowestRoutesOptions = {
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: {
+ x: {
+ beginAtZero: true,
+ ticks: { color: TICK_COLOR },
+ grid: { color: GRID_COLOR },
+ title: { display: true, text: 'p75 LCP (ms)', color: TICK_COLOR },
+ },
+ y: {
+ ticks: { color: TICK_COLOR, font: { size: 11 } },
+ grid: { display: false },
+ },
+ },
+ };
+
+ const prevMap = {};
+ previous.forEach(p => { prevMap[`${p.name}|${p.route}`] = p; });
+
+ const regressions = current
+ .filter(c => {
+ const prev = prevMap[`${c.name}|${c.route}`];
+ return prev && prev.p75 > 0 && c.p75 != null;
+ })
+ .map(c => {
+ const prev = prevMap[`${c.name}|${c.route}`];
+ const delta = ((c.p75 - prev.p75) / prev.p75) * 100;
+ return { ...c, prevP75: prev.p75, delta };
+ })
+ .sort((a, b) => b.delta - a.delta)
+ .slice(0, 5);
+
+ const trendsByMetric = {};
+ if (trends) {
+ trends.forEach(t => {
+ if (!trendsByMetric[t.name]) trendsByMetric[t.name] = [];
+ trendsByMetric[t.name].push(t);
+ });
+ }
+
+ return (
+ <>
+
+ {summaryCards.map(card => (
+
+
p75 {card.name}
+
+ {card.p75 != null ? Math.round(card.p75) : '—'}
+
+
+ {card.p75 != null ? METRIC_THRESHOLDS[card.name]?.unit : ''}
+ {card.count > 0 && ({card.count} samples)}
+
+
+ ))}
+
+
+
+ {lcpByRoute.length > 0 && (
+
+
Slowest Routes by LCP (p75)
+
+
+
+
+ )}
+
+
+
Biggest Changes vs Previous Period
+ {regressions.length > 0 ? (
+
+ {regressions.map((r, i) => {
+ const improved = r.delta < 0;
+ return (
+
+
{r.route}
+
{r.name}
+
+ {improved ? '↓' : '↑'} {Math.abs(r.delta).toFixed(1)}%
+
+
+ {Math.round(r.prevP75)} → {Math.round(r.p75)} {METRIC_THRESHOLDS[r.name]?.unit}
+
+
+ );
+ })}
+
+ ) : (
+
No previous-period data for comparison
+ )}
+
+
+ {['LCP', 'CLS', 'INP', 'TTFB'].map(metric => {
+ const entries = trendsByMetric[metric] || [];
+ return (
+
+ );
+ })}
+
+ >
+ );
+}
diff --git a/src/admin/UsersTab.jsx b/src/admin/UsersTab.jsx
new file mode 100644
index 0000000..78c2240
--- /dev/null
+++ b/src/admin/UsersTab.jsx
@@ -0,0 +1,225 @@
+import React, { useState, useEffect } from 'react';
+import { api as axios } from '../app/api';
+import { ChartBlock, LINE_COLOR } from './Admin';
+
+const constants = require('../app/constants');
+
+function avg(arr) {
+ if (!arr.length) return 0;
+ return Math.round(arr.reduce((s, d) => s + d.count, 0) / arr.length);
+}
+
+function formatDate(iso) {
+ if (!iso) return —;
+ return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+}
+
+function UsersTable() {
+ const [page, setPage] = useState(1);
+ const [sort, setSort] = useState('lastActiveAt');
+ const [order, setOrder] = useState('desc');
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [usersError, setUsersError] = useState('');
+
+ useEffect(() => {
+ setLoading(true);
+ setUsersError('');
+ axios.get(
+ `${constants.SERVER_URL}/api/admin/users?page=${page}&sort=${sort}&order=${order}`,
+ { withCredentials: true }
+ )
+ .then(res => {
+ if (res.data.success) {
+ setData(res.data);
+ } else {
+ setUsersError(res.data.message || 'Failed to load users. Try again.');
+ }
+ })
+ .catch(() => setUsersError('Failed to load users. Try again.'))
+ .finally(() => setLoading(false));
+ }, [page, sort, order]);
+
+ const SORT_FIELDS = ['lastActiveAt', 'createdAt', 'totalRecords'];
+
+ const handleSort = (field) => {
+ if (!SORT_FIELDS.includes(field)) return;
+ if (sort === field) {
+ setOrder(o => o === 'desc' ? 'asc' : 'desc');
+ } else {
+ setSort(field);
+ setOrder('desc');
+ }
+ setPage(1);
+ };
+
+ const sortIcon = (field) => {
+ if (sort !== field) return ↕;
+ return {order === 'desc' ? '↓' : '↑'};
+ };
+
+ return (
+
+
+
All Users
+
+ {data && (
+
+ Page {data.page} of {data.totalPages} ({data.total} total)
+
+ )}
+
+
+
+
+ {!loading && usersError && (
+
+ {usersError}
+
+ )}
+ {loading ? (
+
+ ) : (
+
+
+
+
+ | # |
+ Display Name |
+ Email |
+ handleSort('lastActiveAt')}
+ >
+ Last Active {sortIcon('lastActiveAt')}
+ |
+ handleSort('createdAt')}
+ >
+ Joined {sortIcon('createdAt')}
+ |
+ handleSort('totalRecords')}
+ >
+ Total Records {sortIcon('totalRecords')}
+ |
+
+
+
+ {data?.users.map((u, i) => (
+
+ | {(data.page - 1) * 10 + i + 1} |
+ {u.displayName} |
+ {u.email || —} |
+ {formatDate(u.lastActiveAt)} |
+ {formatDate(u.createdAt)} |
+ {u.totalRecords ?? 0} |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+export default function UsersTab({ range }) {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await axios.get(
+ `${constants.SERVER_URL}/api/admin/stats?range=${range}`,
+ { withCredentials: true }
+ );
+ if (res.data.success) {
+ setStats(res.data);
+ } else {
+ setError('Failed to load admin stats.');
+ }
+ } catch (err) {
+ if (err.response?.status === 403) {
+ setError('Access denied.');
+ } else {
+ setError('Error loading admin stats.');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchStats();
+ }, [range]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) return {error}
;
+ if (!stats) return null;
+
+ const avgDAU = avg(stats.dailyActiveUsers);
+ const avgMAU = avg(stats.monthlyActiveUsers);
+ const totalSignups = stats.newSignupsPerDay.reduce((s, d) => s + d.count, 0);
+
+ return (
+ <>
+
+
+
Total Users
+
{stats.totalUsers.toLocaleString()}
+
+
+
Avg Daily Active
+
{avgDAU.toLocaleString()}
+
+
+
Avg Monthly Active
+
{avgMAU.toLocaleString()}
+
+
+
New Signups (range)
+
{totalSignups.toLocaleString()}
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/vitals.js b/src/app/vitals.js
new file mode 100644
index 0000000..53fd4dd
--- /dev/null
+++ b/src/app/vitals.js
@@ -0,0 +1,49 @@
+import { onLCP, onCLS, onINP, onTTFB } from 'web-vitals';
+
+const SAMPLE_RATE = 1.0;
+const ENDPOINT = '/api/admin/vitals';
+const constants = require('./constants');
+
+function shouldSample() {
+ return Math.random() < SAMPLE_RATE;
+}
+
+const queue = [];
+let flushTimer = null;
+
+function enqueue(metric) {
+ queue.push({
+ name: metric.name,
+ value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
+ rating: metric.rating,
+ route: window.location.pathname.replace(/\/\d+/g, '/:id'),
+ });
+ if (!flushTimer) {
+ flushTimer = setTimeout(flush, 3000);
+ }
+}
+
+function flush() {
+ flushTimer = null;
+ if (!queue.length) return;
+ const batch = queue.splice(0);
+ const url = constants.SERVER_URL + ENDPOINT;
+ if (navigator.sendBeacon) {
+ navigator.sendBeacon(url, JSON.stringify(batch));
+ } else {
+ fetch(url, {
+ method: 'POST',
+ body: JSON.stringify(batch),
+ headers: { 'Content-Type': 'application/json' },
+ keepalive: true,
+ });
+ }
+}
+
+export function initVitals() {
+ if (!shouldSample()) return;
+ onLCP(enqueue);
+ onCLS(enqueue);
+ onINP(enqueue);
+ onTTFB(enqueue);
+}
diff --git a/src/index.js b/src/index.js
index c812377..33aaac5 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,6 +6,7 @@ import "@fortawesome/fontawesome-free/css/fontawesome.min.css";
import "@fortawesome/fontawesome-free/css/solid.min.css";
import { Toaster } from 'sonner';
import App from './App';
+import { initVitals } from './app/vitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
@@ -14,3 +15,5 @@ root.render(
);
+
+initVitals();