Files
Hermes fca56ab77e T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker
- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui
   - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs
   - apps/worker: Bun worker stub, DB connection, graceful SIGTERM
   - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference)
   - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy)
   - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api
   - docker-compose.yml: 4-service target (api, spa, db, worker)
   - packages/db/src/client.ts: shared Drizzle client for api + worker
   - db/client.ts: root-level alias for convenience

   Parent: t_e1cbd87d -> t_24c9c3fd (T0)
2026-08-01 01:15:31 +00:00

105 lines
3.0 KiB
TypeScript

'use client';
import { useEffect } from 'react';
/**
* Reports Core Web Vitals (LCP, CLS, INP) via PerformanceObserver.
* Only runs in production to avoid noise in development.
*/
function reportMetric(name: string, value: number, rating: 'good' | 'needs-improvement' | 'poor') {
if (process.env.NODE_ENV !== 'production') return;
const body = JSON.stringify({
name,
value,
rating,
page: window.location.pathname,
userAgent: navigator.userAgent,
timestamp: Date.now(),
});
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/vitals', body);
}
}
function getLCPRating(value: number): 'good' | 'needs-improvement' | 'poor' {
if (value <= 2500) return 'good';
if (value <= 4000) return 'needs-improvement';
return 'poor';
}
function getCLSRating(value: number): 'good' | 'needs-improvement' | 'poor' {
if (value <= 0.1) return 'good';
if (value <= 0.25) return 'needs-improvement';
return 'poor';
}
function getINPRating(value: number): 'good' | 'needs-improvement' | 'poor' {
if (value <= 200) return 'good';
if (value <= 500) return 'needs-improvement';
return 'poor';
}
export function useWebVitals() {
useEffect(() => {
if (typeof PerformanceObserver === 'undefined') return;
// LCP — Largest Contentful Paint
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1] as unknown as PerformanceEntry & { startTime: number };
if (lastEntry) {
reportMetric('LCP', lastEntry.startTime, getLCPRating(lastEntry.startTime));
}
});
try {
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
} catch {
// Not supported
}
// CLS — Cumulative Layout Shift
let clsValue = 0;
const clsObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!(entry as unknown as { hadRecentInput?: boolean }).hadRecentInput) {
clsValue += (entry as unknown as { value: number }).value;
reportMetric('CLS', clsValue, getCLSRating(clsValue));
}
}
});
try {
clsObserver.observe({ type: 'layout-shift', buffered: true });
} catch {
// Not supported
}
// INP — Interaction to Next Paint
let maxINP = 0;
const inpObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const eventEntry = entry as unknown as { duration: number };
if (eventEntry.duration > maxINP) {
maxINP = eventEntry.duration;
reportMetric('INP', maxINP, getINPRating(maxINP));
}
}
});
try {
inpObserver.observe({ type: 'event', buffered: true });
} catch {
// Not supported
}
return () => {
lcpObserver.disconnect();
clsObserver.disconnect();
inpObserver.disconnect();
};
}, []);
}