Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ jobs:
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run lint
- run: npm run lint
40 changes: 40 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,46 @@ const nextConfig: NextConfig = {
// Many legacy files do not match Prettier; keep type checking without blocking production builds.
ignoreDuringBuilds: true,
},

// ── Asset Versioning & Cache Headers (#326) ──────────────────────────────
// Next.js already content-hashes JS/CSS chunks in /_next/static/.
// We add long-lived cache headers for those immutable assets and a
// short revalidation window for HTML pages so users never see stale UI.
async headers() {
return [
{
// Immutable hashed static assets – cache for 1 year
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
// Public folder assets (images, fonts, etc.) – cache for 7 days
source: '/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=604800, stale-while-revalidate=86400',
},
],
},
{
// HTML pages – always revalidate so deployments are picked up quickly
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=0, must-revalidate',
},
],
},
];
},

images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
Expand Down
86 changes: 86 additions & 0 deletions src/hooks/useApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use client';

import { useState, useEffect, useCallback, useRef } from 'react';
import { buildDedupeKey, dedupe, cancelDedupe } from '@/lib/api/dedupe';

export type ApiState<T> = {
data: T | null;
loading: boolean;
error: Error | null;
};

export type UseApiOptions = {
/** Skip the initial fetch (manual trigger only). */
skip?: boolean;
/** Extra data included in the dedupe key (e.g. request body). */
body?: unknown;
};

/**
* Hook for data fetching with automatic request deduplication.
*
* Concurrent calls with the same method + url + body share a single
* in-flight request instead of firing duplicate network calls.
*/
export function useApi<T>(
url: string,
options: RequestInit & UseApiOptions = {},
): ApiState<T> & { refetch: () => void } {
const { skip = false, body, ...fetchOptions } = options;
const method = fetchOptions.method ?? 'GET';

const [state, setState] = useState<ApiState<T>>({
data: null,
loading: !skip,
error: null,
});

// Track whether the component is still mounted to avoid state updates after unmount.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);

const fetchData = useCallback(async () => {
const key = buildDedupeKey(method, url, body);

if (mountedRef.current) {
setState((prev) => ({ ...prev, loading: true, error: null }));
}

try {
const data = await dedupe<T>(key, () =>
fetch(url, {
...fetchOptions,
method,
...(body ? { body: JSON.stringify(body) } : {}),
}).then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}),
);

if (mountedRef.current) {
setState({ data, loading: false, error: null });
}
} catch (err) {
if (mountedRef.current) {
setState({ data: null, loading: false, error: err instanceof Error ? err : new Error(String(err)) });
}
}
}, [url, method, body]); // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
if (!skip) {
fetchData();
}
return () => {
cancelDedupe(buildDedupeKey(method, url, body));
};
}, [skip, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps

return { ...state, refetch: fetchData };
}
74 changes: 74 additions & 0 deletions src/lib/api/dedupe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Request Deduplication Cache (#323)
*
* Merges concurrent identical requests so only one network call is made.
* Subsequent callers receive the same promise as the in-flight request.
*/

type Resolver<T> = {
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};

interface InFlight<T> {
promise: Promise<T>;
resolvers: Resolver<T>[];
}

const cache = new Map<string, InFlight<unknown>>();

/**
* Build a stable cache key from method + url + optional body.
*/
export function buildDedupeKey(method: string, url: string, body?: unknown): string {
const base = `${method.toUpperCase()}:${url}`;
return body ? `${base}:${JSON.stringify(body)}` : base;
}

/**
* Deduplicate an async request factory.
*
* If a request with the same key is already in-flight, the caller receives
* the same promise instead of triggering a new network call.
*
* @param key - Unique identifier for this request (use buildDedupeKey)
* @param fn - Factory that performs the actual request
*/
export async function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = cache.get(key) as InFlight<T> | undefined;
if (existing) {
return existing.promise;
}

let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;

const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});

const entry: InFlight<T> = { promise, resolvers: [{ resolve, reject }] };
cache.set(key, entry as InFlight<unknown>);

try {
const result = await fn();
resolve(result);
return result;
} catch (err) {
reject(err);
throw err;
} finally {
cache.delete(key);
}
}

/** Remove a specific key from the cache (e.g. on cancellation). */
export function cancelDedupe(key: string): void {
cache.delete(key);
}

/** Clear the entire deduplication cache. */
export function clearDedupeCache(): void {
cache.clear();
}
121 changes: 121 additions & 0 deletions src/lib/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Error Tracking Integration (#327)
*
* Wires a Sentry-compatible interface over the existing errorReportingService.
* Drop-in: swap the stub init() for a real Sentry.init() call when the SDK
* is installed, without changing any call-sites.
*/

import { errorReportingService, BreadcrumbEntry } from '@/services/errorReporting';

// ── Types ────────────────────────────────────────────────────────────────────

export interface ErrorContext {
userId?: string;
tags?: Record<string, string>;
extra?: Record<string, unknown>;
}

export interface Breadcrumb {
category: string;
message?: string;
data?: Record<string, unknown>;
level?: 'debug' | 'info' | 'warning' | 'error';
}

// ── Sentry-compatible stub ────────────────────────────────────────────────────
// Replace the body of each function with the real Sentry SDK call once
// `@sentry/nextjs` is installed.

let _initialized = false;

/**
* Initialise the error tracking SDK.
* Call once at application startup (e.g. in instrumentation.ts).
*/
export function init(dsn?: string): void {
if (_initialized) return;
_initialized = true;

// TODO: replace with Sentry.init({ dsn, ... }) when SDK is installed.
if (dsn) {
console.info('[ErrorTracking] Initialised with DSN:', dsn);
}

// Forward global unhandled errors to the reporting service automatically.
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (event) => {
captureException(event.reason, { extra: { type: 'unhandledRejection' } });
});
window.addEventListener('error', (event) => {
captureException(event.error ?? new Error(event.message), {
extra: { filename: event.filename, lineno: event.lineno },
});
});
}
}

/**
* Capture an exception with optional context.
* Mirrors Sentry.captureException().
*/
export function captureException(error: unknown, context?: ErrorContext): void {
const err = error instanceof Error ? error : new Error(String(error));

if (context?.userId) {
errorReportingService.setUserId(context.userId);
}

addBreadcrumb({
category: 'exception',
message: err.message,
data: { stack: err.stack, ...context?.extra },
level: 'error',
});

errorReportingService.reportError(err, { tags: context?.tags, extra: context?.extra });
}

/**
* Capture a plain message (non-exception).
* Mirrors Sentry.captureMessage().
*/
export function captureMessage(message: string, context?: ErrorContext): void {
addBreadcrumb({ category: 'message', message, level: 'info', data: context?.extra });
errorReportingService.reportError(new Error(message), context);
}

/**
* Add a breadcrumb for richer error context.
* Mirrors Sentry.addBreadcrumb().
*/
export function addBreadcrumb(breadcrumb: Breadcrumb): void {
errorReportingService.addBreadcrumb(breadcrumb.category, {
message: breadcrumb.message,
level: breadcrumb.level,
...breadcrumb.data,
});
}

/**
* Attach user identity to subsequent error reports.
* Mirrors Sentry.setUser().
*/
export function setUser(user: { id: string; [key: string]: unknown } | null): void {
if (user) {
errorReportingService.setUserId(user.id);
addBreadcrumb({ category: 'user', message: 'User identified', data: { userId: user.id } });
} else {
errorReportingService.clearUserId();
}
}

/**
* Retrieve current breadcrumbs (useful for diagnostics / tests).
*/
export function getBreadcrumbs(): BreadcrumbEntry[] {
return errorReportingService.getBreadcrumbs();
}

// Re-export the underlying service for advanced use-cases.
export { errorReportingService };
Loading
Loading