diff --git a/apps/ui/public/_headers b/apps/ui/public/_headers index c4ce09c..df31f68 100644 --- a/apps/ui/public/_headers +++ b/apps/ui/public/_headers @@ -3,7 +3,7 @@ X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() - Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-v1Z7Kqt3raaiGY/lFSvXBk1xU46FLzlYiiPgmqkaa0Y=' https://static.cloudflareinsights.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; frame-src https://challenges.cloudflare.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none' + Content-Security-Policy: default-src 'none'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self' https://cloudflareinsights.com; frame-src https://challenges.cloudflare.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none' /assets/* Cache-Control: public, max-age=31536000, immutable diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 0b4f342..a2b8416 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -11,6 +11,7 @@ import { } from "./features/navigation/app-route"; import type { AppRoute } from "./features/navigation/app-route"; import { PrivacyPolicyView } from "./features/navigation/privacy-policy-view"; +import { StatsView } from "./features/stats/stats-view"; type NavItem = Readonly<{ code: string; @@ -34,12 +35,18 @@ const appNavItems: readonly NavItem[] = [ label: "Privacy policy", route: "privacy", }, + { + code: "04", + label: "Stats", + route: "stats", + }, ]; const appRouteTitles: Record = { home: "Rehoboam \u2014 Divergence", hud: "Developer HUD \u2014 Rehoboam", privacy: "Privacy Policy \u2014 Rehoboam", + stats: "Stats \u2014 Rehoboam", }; export const App = () => { @@ -108,7 +115,22 @@ export const App = () => { mainRef.current?.focus(); }, [appRoute]); - const isSceneRoute = appRoute !== "privacy"; + const viewClassName = + appRoute === "home" || appRoute === "hud" + ? "app-shell__view app-shell__view--scene" + : `app-shell__view app-shell__view--${appRoute}`; + + const renderView = () => { + switch (appRoute) { + case "home": + case "hud": + return ; + case "stats": + return ; + case "privacy": + return ; + } + }; return (
@@ -136,19 +158,7 @@ export const App = () => {
-
- {isSceneRoute ? ( - - ) : ( - - )} -
+
{renderView()}
); diff --git a/apps/ui/src/features/navigation/app-route.ts b/apps/ui/src/features/navigation/app-route.ts index 378fe2a..7193be5 100644 --- a/apps/ui/src/features/navigation/app-route.ts +++ b/apps/ui/src/features/navigation/app-route.ts @@ -1,4 +1,4 @@ -export type AppRoute = "home" | "hud" | "privacy"; +export type AppRoute = "home" | "hud" | "privacy" | "stats"; const normalizePathname = (pathname: string): string => { return pathname.trim().toLowerCase().replace(/\/+$/u, "") || "/"; @@ -14,6 +14,8 @@ export const getAppRouteFromPathname = (pathname: string): AppRoute => { case "/privacy": case "/privacy-policy": return "privacy"; + case "/stats": + return "stats"; default: return "home"; } @@ -27,5 +29,7 @@ export const getPathForAppRoute = (route: AppRoute): string => { return "/hud"; case "privacy": return "/privacy"; + case "stats": + return "/stats"; } }; diff --git a/apps/ui/src/features/navigation/app-shell.css b/apps/ui/src/features/navigation/app-shell.css index 717a80e..d154fea 100644 --- a/apps/ui/src/features/navigation/app-shell.css +++ b/apps/ui/src/features/navigation/app-shell.css @@ -159,6 +159,10 @@ min-width: 202px; } +.app-shell__nav-link--stats { + min-width: 134px; +} + .app-shell__nav-meta { position: relative; z-index: 1; @@ -199,7 +203,8 @@ height: 100dvh; } -.app-shell__view--privacy { +.app-shell__view--privacy, +.app-shell__view--stats { min-height: 100dvh; box-sizing: border-box; display: flex; @@ -315,7 +320,7 @@ } .app-shell__nav { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .app-shell__nav-link { @@ -365,7 +370,8 @@ ); } - .app-shell__view--privacy { + .app-shell__view--privacy, + .app-shell__view--stats { align-items: flex-start; padding: clamp(18px, 5vw, 24px) var(--app-shell-gutter) calc( diff --git a/apps/ui/src/features/stats/stats-view.css b/apps/ui/src/features/stats/stats-view.css new file mode 100644 index 0000000..5935ddf --- /dev/null +++ b/apps/ui/src/features/stats/stats-view.css @@ -0,0 +1,282 @@ +.stats-view { + width: 100%; + display: flex; + justify-content: center; +} + +.stats-view__panel { + width: min(100%, 960px); + padding: clamp(24px, 3vw, 40px); + border: 1px solid rgba(18, 18, 18, 0.1); + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(14px); + box-shadow: 0 18px 42px rgba(10, 10, 10, 0.08); + font-family: var(--hud-font); +} + +.stats-view__eyebrow { + margin: 0; + color: var(--hud-time); + font-size: 0.82rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.stats-view__title { + margin: 8px 0 0; + color: var(--hud-strong); + font-size: clamp(2.2rem, 4.2vw, 3.6rem); + font-weight: 500; + line-height: 0.9; + text-transform: uppercase; +} + +.stats-view__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 28px; +} + +/* ── Total ── */ + +.stats-view__total { + grid-column: 1 / -1; + padding: 18px 0; + border-top: 1px solid rgba(18, 18, 18, 0.08); +} + +.stats-view__total-label { + margin: 0; + color: var(--hud-time); + font-size: 0.82rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.stats-view__total-value { + margin: 4px 0 0; + color: var(--hud-strong); + font-size: clamp(2.8rem, 5vw, 4.2rem); + font-weight: 500; + line-height: 1; + letter-spacing: -0.02em; +} + +/* ── Activity ── */ + +.stats-view__activity { + grid-column: 1 / -1; + padding: 18px 0; + border-top: 1px solid rgba(18, 18, 18, 0.08); +} + +.stats-view__section-title { + margin: 0; + color: var(--hud-strong); + font-size: 1.18rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.stats-view__activity-chart { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px; + align-items: end; + height: 120px; + margin-top: 14px; +} + +.stats-view__activity-column { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; +} + +.stats-view__activity-bar { + width: 100%; + max-width: 48px; + height: calc(var(--bar-pct) * 1%); + min-height: 2px; + background: rgba(18, 18, 18, 0.14); + transition: height 600ms ease; +} + +.stats-view__activity-labels { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px; + margin-top: 6px; +} + +.stats-view__activity-day { + text-align: center; + color: var(--hud-soft); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.stats-view__activity-count { + text-align: center; + color: var(--hud-mid); + font-size: 0.68rem; + letter-spacing: 0.04em; + margin-top: 2px; +} + +/* ── Categories ── */ + +.stats-view__categories { + padding: 18px 0; + border-top: 1px solid rgba(18, 18, 18, 0.08); +} + +.stats-view__category-rows { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 14px; +} + +.stats-view__category-row { + display: grid; + grid-template-columns: 100px 1fr 40px; + gap: 10px; + align-items: center; +} + +.stats-view__category-label { + color: var(--hud-mid); + font-size: 0.82rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.stats-view__category-bar-track { + height: 4px; + background: rgba(18, 18, 18, 0.04); +} + +.stats-view__category-bar-fill { + height: 100%; + width: calc(var(--bar-pct) * 1%); + background: rgba(18, 18, 18, 0.24); + transition: width 600ms ease; +} + +.stats-view__category-count { + text-align: right; + color: var(--hud-strong); + font-size: 0.88rem; + letter-spacing: 0.02em; +} + +/* ── Severity ── */ + +.stats-view__severity { + padding: 18px 0; + border-top: 1px solid rgba(18, 18, 18, 0.08); +} + +.stats-view__severity-items { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 14px; +} + +.stats-view__severity-item { + padding: 10px 12px; + border-left: 3px solid var(--severity-color, rgba(18, 18, 18, 0.14)); +} + +.stats-view__severity-label { + color: var(--hud-mid); + font-size: 0.76rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.stats-view__severity-value { + margin-top: 2px; + color: var(--hud-strong); + font-size: 1.6rem; + font-weight: 500; + line-height: 1; +} + +/* ── Skeleton ── */ + +@keyframes stats-view-pulse { + 0%, + 100% { + opacity: 0.3; + } + + 50% { + opacity: 0.6; + } +} + +.stats-view__skeleton { + animation: stats-view-pulse 1.6s ease-in-out infinite; +} + +/* ── Error ── */ + +.stats-view__error { + grid-column: 1 / -1; + padding: 48px 0; + text-align: center; +} + +.stats-view__error-title { + margin: 0; + color: var(--hud-strong); + font-size: clamp(1.8rem, 3.6vw, 2.8rem); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.stats-view__error-message { + margin: 8px 0 0; + color: var(--hud-mid); + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* ── Responsive ── */ + +@media (max-width: 960px) { + .stats-view__panel { + width: 100%; + padding: clamp(20px, 5vw, 28px); + } + + .stats-view__grid { + grid-template-columns: 1fr; + } + + .stats-view__severity-items { + grid-template-columns: 1fr 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .stats-view__activity-bar, + .stats-view__category-bar-fill { + transition: none; + } + + .stats-view__skeleton { + animation: none; + opacity: 0.4; + } +} diff --git a/apps/ui/src/features/stats/stats-view.tsx b/apps/ui/src/features/stats/stats-view.tsx new file mode 100644 index 0000000..ed9a0c7 --- /dev/null +++ b/apps/ui/src/features/stats/stats-view.tsx @@ -0,0 +1,239 @@ +import type { StatsResponse } from "@repo/types"; +import type { CSSProperties } from "react"; + +import { useStats } from "./use-stats"; + +import "./stats-view.css"; + +const dayFormatter = new Intl.DateTimeFormat("en-US", { + weekday: "short", + timeZone: "UTC", +}); + +const formatDay = (dateString: string): string => { + const date = new Date(`${dateString}T00:00:00Z`); + + return dayFormatter.format(date); +}; + +const SEVERITY_COLORS: Record = { + critical: "rgba(18, 18, 18, 0.82)", + high: "rgba(18, 18, 18, 0.54)", + medium: "rgba(18, 18, 18, 0.32)", + low: "rgba(18, 18, 18, 0.16)", +}; + +const SEVERITY_ORDER = ["critical", "high", "medium", "low"] as const; + +type TotalsProps = Readonly<{ + totals: StatsResponse["totals"] | null; +}>; + +const StatsTotals = ({ totals }: TotalsProps) => { + const isLoading = totals === null; + + return ( +
+

Total Events

+

+ {isLoading ? "--" : totals.events.toLocaleString()} +

+
+ ); +}; + +type ActivityProps = Readonly<{ + recentActivity: StatsResponse["recentActivity"] | null; +}>; + +const StatsActivity = ({ recentActivity }: ActivityProps) => { + const isLoading = recentActivity === null; + const maxCount = isLoading + ? 1 + : Math.max(...recentActivity.map((d) => d.count), 1); + + const items = isLoading + ? Array.from({ length: 7 }, (_, i) => ({ + date: "", + count: 0, + key: `skeleton-${String(i)}`, + })) + : recentActivity.map((d) => ({ ...d, key: d.date })); + + return ( +
+

7-Day Activity

+
`${formatDay(d.date)}: ${String(d.count)}`).join(", ")}` + } + > + {items.map((item) => ( +
+
+
+ ))} +
+ +
+ ); +}; + +type CategoriesProps = Readonly<{ + byCategory: StatsResponse["byCategory"] | null; +}>; + +const StatsCategories = ({ byCategory }: CategoriesProps) => { + const isLoading = byCategory === null; + + const entries = isLoading + ? [] + : Object.entries(byCategory) + .sort(([, a], [, b]) => b - a) + .map(([category, count]) => ({ category, count })); + + const maxCount = isLoading ? 1 : Math.max(...entries.map((e) => e.count), 1); + + return ( +
+

By Category

+
+ {isLoading + ? Array.from({ length: 9 }, (_, i) => ( +
+ + -- + +
+
+
+ + -- + +
+ )) + : entries.map((entry) => ( +
+ + {entry.category} + +
+
+
+ + {entry.count} + +
+ ))} +
+
+ ); +}; + +type SeverityProps = Readonly<{ + bySeverity: StatsResponse["bySeverity"] | null; +}>; + +const StatsSeverity = ({ bySeverity }: SeverityProps) => { + const isLoading = bySeverity === null; + + return ( +
+

By Severity

+
+ {SEVERITY_ORDER.map((severity) => ( +
+
{severity}
+
+ {isLoading ? "--" : bySeverity[severity]} +
+
+ ))} +
+
+ ); +}; + +const StatsError = () => ( +
+

Signal Lost

+

Failed to load statistics

+
+); + +export const StatsView = () => { + const { data, status } = useStats(); + + return ( +
+
+
+

System Analytics

+

+ Event Statistics +

+
+ +
+ {status === "error" ? ( + + ) : ( + <> + + + + + + )} +
+
+
+ ); +}; diff --git a/apps/ui/src/features/stats/use-stats.ts b/apps/ui/src/features/stats/use-stats.ts new file mode 100644 index 0000000..09bf375 --- /dev/null +++ b/apps/ui/src/features/stats/use-stats.ts @@ -0,0 +1,52 @@ +import { StatsResponseSchema } from "@repo/types"; +import type { StatsResponse } from "@repo/types"; +import { useEffect, useState } from "react"; + +type StatsStatus = "loading" | "success" | "error"; + +type StatsState = Readonly<{ + data: StatsResponse | null; + status: StatsStatus; +}>; + +export const useStats = (): StatsState => { + const [state, setState] = useState({ + data: null, + status: "loading", + }); + + useEffect(() => { + const abortController = new AbortController(); + + const load = async () => { + try { + const response = await fetch("/api/stats", { + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const json: unknown = await response.json(); + const data = StatsResponseSchema.parse(json); + + if (!abortController.signal.aborted) { + setState({ data, status: "success" }); + } + } catch { + if (!abortController.signal.aborted) { + setState({ data: null, status: "error" }); + } + } + }; + + void load(); + + return () => { + abortController.abort(); + }; + }, []); + + return state; +}; diff --git a/apps/ui/src/main.tsx b/apps/ui/src/main.tsx index 11854a7..2282a42 100644 --- a/apps/ui/src/main.tsx +++ b/apps/ui/src/main.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@fontsource/barlow-condensed/latin-400.css"; @@ -15,9 +15,9 @@ if (!rootElement) { } createRoot(rootElement).render( - + - + ); diff --git a/apps/ui/src/tests/navigation/app-route.test.ts b/apps/ui/src/tests/navigation/app-route.test.ts index 1349af5..7e8ebf5 100644 --- a/apps/ui/src/tests/navigation/app-route.test.ts +++ b/apps/ui/src/tests/navigation/app-route.test.ts @@ -8,6 +8,7 @@ describe("app route helpers", () => { expect(getAppRouteFromPathname("/")).toBe("home"); expect(getAppRouteFromPathname("/hud")).toBe("hud"); expect(getAppRouteFromPathname("/privacy")).toBe("privacy"); + expect(getAppRouteFromPathname("/stats")).toBe("stats"); }); it("treats unknown pathnames as home", () => { @@ -19,5 +20,6 @@ describe("app route helpers", () => { expect(getPathForAppRoute("home")).toBe("/"); expect(getPathForAppRoute("hud")).toBe("/hud"); expect(getPathForAppRoute("privacy")).toBe("/privacy"); + expect(getPathForAppRoute("stats")).toBe("/stats"); }); }); diff --git a/apps/ui/src/tests/shared/errors/app-error-boundary.test.tsx b/apps/ui/src/tests/shared/errors/app-error-boundary.test.tsx index 6f9246c..c94a877 100644 --- a/apps/ui/src/tests/shared/errors/app-error-boundary.test.tsx +++ b/apps/ui/src/tests/shared/errors/app-error-boundary.test.tsx @@ -1,4 +1,4 @@ -import React, { act } from "react"; +import { act, StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { AppErrorBoundary } from "../../../shared/errors/app-error-boundary"; @@ -41,11 +41,11 @@ describe("AppErrorBoundary", () => { act(() => { root?.render( - +
Nominal UI
-
+ ); }); @@ -61,11 +61,11 @@ describe("AppErrorBoundary", () => { act(() => { root?.render( - + - + ); }); diff --git a/tooling/eslint/react.ts b/tooling/eslint/react.ts index 98ecc90..dfad8af 100644 --- a/tooling/eslint/react.ts +++ b/tooling/eslint/react.ts @@ -20,6 +20,24 @@ export const reactConfig = defineConfig( { files: ["**/*.ts", "**/*.tsx"], rules: { + "no-restricted-syntax": [ + "error", + { + selector: "MemberExpression[object.name='React']", + message: + "Avoid `React.` namespace usage. Import the specific symbol instead.", + }, + { + selector: "TSQualifiedName[left.name='React']", + message: + "Avoid `React.` type namespace usage. Import the type directly instead.", + }, + { + selector: "JSXMemberExpression[object.name='React']", + message: + "Avoid `React.` namespace usage in JSX. Import the component instead.", + }, + ], // React compiler rules are too strict for the current codebase "react-hooks/set-state-in-effect": "off", "react-hooks/preserve-manual-memoization": "off",