Files
goodgo-platform/apps/web/lib/web-vitals.ts
Ho Ngoc Hai 8ca64e3267 feat(web): add saved searches, image lightbox, and web vitals tracking
New features:
- Saved searches dashboard page with CRUD hooks and API client
- Image lightbox component for property gallery full-screen viewing
- Web vitals provider and reporting utilities for performance monitoring
- Image blur placeholder generation utility

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 01:39:22 +07:00

120 lines
3.2 KiB
TypeScript

import type { Metric } from 'web-vitals';
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
/**
* Baseline targets for Core Web Vitals:
* LCP < 2.5s (good), < 4.0s (needs improvement)
* FCP < 1.8s (good), < 3.0s (needs improvement)
* CLS < 0.1 (good), < 0.25 (needs improvement)
* TTFB < 800ms (good)
* INP < 200ms (good), < 500ms (needs improvement)
*/
export const WEB_VITALS_THRESHOLDS = {
LCP: { good: 2500, poor: 4000 },
FCP: { good: 1800, poor: 3000 },
CLS: { good: 0.1, poor: 0.25 },
TTFB: { good: 800, poor: 1800 },
INP: { good: 200, poor: 500 },
} as const;
type VitalName = keyof typeof WEB_VITALS_THRESHOLDS;
function getRating(name: string, value: number): 'good' | 'needs-improvement' | 'poor' {
const thresholds = WEB_VITALS_THRESHOLDS[name as VitalName];
if (!thresholds) return 'good';
if (value <= thresholds.good) return 'good';
if (value <= thresholds.poor) return 'needs-improvement';
return 'poor';
}
interface WebVitalPayload {
name: string;
value: number;
rating: string;
delta: number;
id: string;
navigationType: string;
url: string;
timestamp: number;
}
/** Queue metrics and flush in batches to avoid per-metric requests. */
const queue: WebVitalPayload[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL_MS = 5000;
const MAX_BATCH_SIZE = 10;
function flushQueue(): void {
if (queue.length === 0) return;
const batch = queue.splice(0, MAX_BATCH_SIZE);
const body = JSON.stringify({ metrics: batch });
// Use sendBeacon for reliability during page unload; fall back to fetch
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
const sent = navigator.sendBeacon(`${API_BASE_URL}/web-vitals`, blob);
if (!sent) {
sendViaFetch(body);
}
} else {
sendViaFetch(body);
}
}
function sendViaFetch(body: string): void {
fetch(`${API_BASE_URL}/web-vitals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => {
// Silently drop — metrics are best-effort
});
}
function enqueue(payload: WebVitalPayload): void {
queue.push(payload);
if (queue.length >= MAX_BATCH_SIZE) {
if (flushTimer) clearTimeout(flushTimer);
flushTimer = null;
flushQueue();
return;
}
if (!flushTimer) {
flushTimer = setTimeout(() => {
flushTimer = null;
flushQueue();
}, FLUSH_INTERVAL_MS);
}
}
/** Report a single web-vitals Metric to the backend. */
export function reportWebVital(metric: Metric): void {
const payload: WebVitalPayload = {
name: metric.name,
value: metric.value,
rating: metric.rating ?? getRating(metric.name, metric.value),
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType ?? 'unknown',
url: typeof location !== 'undefined' ? location.pathname : '',
timestamp: Date.now(),
};
enqueue(payload);
}
/** Flush any remaining metrics (call on page hide / unload). */
export function flushWebVitals(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushQueue();
}