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 ? ( -
-
- Loading... -
-
- ) : ( -
- - - - - - - - - - - - - {data?.users.map((u, i) => ( - - - - - - - - - ))} - -
#Display NameEmail handleSort('lastActiveAt')} - > - Last Active {sortIcon('lastActiveAt')} - handleSort('createdAt')} - > - Joined {sortIcon('createdAt')} - handleSort('totalRecords')} - > - Total Records {sortIcon('totalRecords')} -
{(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 ( -
-
{error}
-
- ); - } - - 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 ( +
+
+ Loading... +
+
+ ); + } + + 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 ? ( +
+
+ Loading... +
+
+ ) : ( +
+ + + + + + + + + + + + + {data?.users.map((u, i) => ( + + + + + + + + + ))} + +
#Display NameEmail handleSort('lastActiveAt')} + > + Last Active {sortIcon('lastActiveAt')} + handleSort('createdAt')} + > + Joined {sortIcon('createdAt')} + handleSort('totalRecords')} + > + Total Records {sortIcon('totalRecords')} +
{(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 ( +
+
+ Loading... +
+
+ ); + } + + 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();