diff --git a/app/offline/page.tsx b/app/offline/page.tsx
new file mode 100644
index 000000000..8ddf9d934
--- /dev/null
+++ b/app/offline/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next';
+import OfflineFallback from '@/components/pwa/OfflineFallback';
+
+export const metadata: Metadata = {
+ title: 'Offline | CommitPulse',
+ description: 'Connection lost. Please check your internet connection.',
+ robots: {
+ index: false,
+ follow: false,
+ },
+};
+
+export default function OfflinePage() {
+ return ;
+}
diff --git a/app/sw.ts b/app/sw.ts
index 34c5c77fd..ebf8a60fc 100644
--- a/app/sw.ts
+++ b/app/sw.ts
@@ -1,6 +1,6 @@
import { defaultCache } from '@serwist/next/worker';
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist';
-import { Serwist } from 'serwist';
+import { Serwist, NetworkFirst } from 'serwist';
// Extend the ServiceWorkerGlobalScope with Serwist's injected manifest
declare global {
@@ -22,7 +22,34 @@ const serwist = new Serwist({
// Prefetch responses while the browser handles navigation
navigationPreload: true,
- runtimeCaching: defaultCache,
+ runtimeCaching: [
+ {
+ matcher({ request }) {
+ return request.mode === 'navigate';
+ },
+ handler: new NetworkFirst({
+ cacheName: 'pages',
+ plugins: [
+ {
+ async handlerDidError() {
+ return caches.match('/offline');
+ },
+ },
+ ],
+ }),
+ },
+ ...defaultCache,
+ ],
+ fallbacks: {
+ entries: [
+ {
+ url: '/offline',
+ matcher({ request }) {
+ return request.mode === 'navigate';
+ },
+ },
+ ],
+ },
});
serwist.addEventListeners();
diff --git a/components/pwa/OfflineFallback.tsx b/components/pwa/OfflineFallback.tsx
new file mode 100644
index 000000000..881d23dd5
--- /dev/null
+++ b/components/pwa/OfflineFallback.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useState } from 'react';
+import { WifiOff, RefreshCw } from 'lucide-react';
+import { useTranslation } from '@/context/TranslationContext';
+
+export default function OfflineFallback() {
+ const { t } = useTranslation();
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const handleRetry = () => {
+ setIsRefreshing(true);
+ // Attempt reload
+ window.location.reload();
+ };
+
+ return (
+
+
+
+
+ {t('offline.title', { defaultValue: 'Connection Lost' })}
+
+
+
+ {t('offline.description', {
+ defaultValue:
+ 'You are currently offline. Check your internet connection and try refreshing the page.',
+ })}
+
+
+
+
+ );
+}
diff --git a/lib/cache.ts b/lib/cache.ts
index b6bec0dd6..5e42c3195 100644
--- a/lib/cache.ts
+++ b/lib/cache.ts
@@ -2,6 +2,24 @@ import { randomUUID } from 'crypto';
import { brotliCompressSync, brotliDecompressSync } from 'zlib';
import logger from '@/lib/logger';
+export const CACHE_VERSION = 'v1';
+
+export interface CacheEnvelope {
+ v: string;
+ t: number; // Write timestamp
+ ttl: number; // Nominal TTL in milliseconds
+ d: T | Buffer;
+}
+
+export interface CacheStats {
+ hits: number;
+ misses: number;
+ writes: number;
+ evictions: number;
+ swrRefreshes: number;
+ size: number;
+}
+
/**
* Configuration options for the distributed mutex lock used by {@link DistributedCache.getOrSet}.
*/
@@ -38,9 +56,10 @@ export interface LockConfig {
* Represents a cached item with its expiration timestamp.
*/
type CacheItem = {
- value: T;
- expiresAt: number;
+ value: CacheEnvelope;
+ expiresAt: number; // Physical cache eviction time
};
+
/**
* A Simple in-memory TTL(Time To Live) cache.
*
@@ -50,12 +69,18 @@ type CacheItem = {
* @typeParam T - Type of values stored in the cache.
*/
export class TTLCache {
- //private store = new Map>();
-
- private store = new Map>();
-
+ private store = new Map>();
private cleanupInterval: ReturnType | null = null;
private readonly maxSize?: number;
+
+ private stats: Omit = {
+ hits: 0,
+ misses: 0,
+ writes: 0,
+ evictions: 0,
+ swrRefreshes: 0,
+ };
+
private static assertValidKey(key: unknown): asserts key is string {
if (typeof key !== 'string') {
throw new TypeError('Cache key must be a string');
@@ -65,6 +90,7 @@ export class TTLCache {
throw new TypeError('Cache key cannot be empty');
}
}
+
/**
* Creates a new TTL cache instance.
*
@@ -134,58 +160,110 @@ export class TTLCache {
return stored;
}
- /**
- * Retrieves a value from the cache.
- *
- * Returns 'null' if the key does not exist or if the entry has expired.
- *
- * @param key - Cache key.
- * @returns The cached value or 'null'.
- *
- * @example
- * const user = cache.get("user:1");
- */
- get(key: string): T | null {
- //TTLCache.assertValidKey(key);
- if (key === null || key === undefined) {
- throw new TypeError('Cache key must be a string');
+ private wrap(value: T | Buffer, ttl: number): CacheEnvelope {
+ return {
+ v: CACHE_VERSION,
+ t: Date.now(),
+ ttl,
+ d: value,
+ };
+ }
+
+ private unwrap(item: unknown): CacheEnvelope | null {
+ if (item && typeof item === 'object' && 'v' in item && 'd' in item && 't' in item) {
+ const env = item as CacheEnvelope;
+ if (env.v === CACHE_VERSION) {
+ return env;
+ }
+ return null; // Version mismatch
+ }
+ // Backward compatibility for legacy cached entries (treat as v1 wrapper on the fly)
+ if (item !== null && item !== undefined) {
+ return {
+ v: CACHE_VERSION,
+ t: Date.now() - 60 * 1000,
+ ttl: 60 * 1000,
+ d: item as T | Buffer,
+ };
}
+ return null;
+ }
- if (typeof key !== 'string') {
+ getStats(): CacheStats {
+ return {
+ ...this.stats,
+ size: this.store.size,
+ };
+ }
+
+ incrementSwrRefreshes(): void {
+ this.stats.swrRefreshes++;
+ }
+
+ invalidatePattern(pattern: RegExp | string): number {
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
+ let count = 0;
+ for (const key of this.store.keys()) {
+ if (regex.test(key)) {
+ this.store.delete(key);
+ count++;
+ }
+ }
+ return count;
+ }
+
+ getWithMetadata(key: string): { value: T; expiresAt: number; writtenAt: number } | null {
+ if (key === null || key === undefined || typeof key !== 'string') {
throw new TypeError('Cache key must be a string');
}
const hit = this.store.get(key);
- if (!hit) return null;
+ if (!hit) {
+ this.stats.misses++;
+ return null;
+ }
if (Date.now() > hit.expiresAt) {
this.store.delete(key);
+ this.stats.misses++;
return null;
}
- return this.decompress(hit.value);
+ const unwrapped = this.unwrap(hit.value);
+ if (!unwrapped) {
+ this.store.delete(key);
+ this.stats.misses++;
+ return null;
+ }
+
+ this.stats.hits++;
+ return {
+ value: this.decompress(unwrapped.d),
+ expiresAt: unwrapped.t + unwrapped.ttl, // return nominal expiration
+ writtenAt: unwrapped.t,
+ };
}
/**
- * Checks whether a key exists in the cache and has not expired.
- *
- * Unlike `get()`, this does not return the value.
- *
- * @param key - Cache key.
- * @returns `true` if the key exists and is still valid, `false` otherwise.
+ * Retrieves a value from the cache.
*
- * @example
- * if (cache.has("user:1")) {
- * // safe to call get()
- * }
+ * Returns 'null' if the key does not exist or if the entry has expired.
*/
- has(key: string): boolean {
- //TTLCache.assertValidKey(key);
- if (key === null || key === undefined) {
- throw new TypeError('Cache key must be a string');
+ get(key: string): T | null {
+ const meta = this.getWithMetadata(key);
+ if (!meta) return null;
+ // Standard get should return null if past nominal expiration
+ if (Date.now() > meta.expiresAt) {
+ return null;
}
+ return meta.value;
+ }
- if (typeof key !== 'string') {
+ /**
+ * Checks whether a key exists in the cache and has not expired.
+ */
+ has(key: string): boolean {
+ if (key === null || key === undefined || typeof key !== 'string') {
throw new TypeError('Cache key must be a string');
}
@@ -197,45 +275,30 @@ export class TTLCache {
return false;
}
+ const unwrapped = this.unwrap(hit.value);
+ if (!unwrapped) {
+ this.store.delete(key);
+ return false;
+ }
+
+ // Standard check has() should be false if past nominal expiration
+ if (Date.now() > unwrapped.t + unwrapped.ttl) {
+ return false;
+ }
+
return true;
}
+
/**
* Removes a single entry from the cache.
- *
- * Does nothing if the key does not exist.
- *
- * @param key - Cache key to remove.
- * @returns `true` if the key existed and was deleted, `false` otherwise.
- *
- * @example
- * cache.delete("user:1");
*/
delete(key: string): boolean {
TTLCache.assertValidKey(key);
-
return this.store.delete(key);
}
- /**
- * Stores a value in the cache with a TTL.
- *
- * If the cache reaches its maximum capacity, the oldest item
- * may be removed to make room for new entries.
- *
- * @param key - Cache key.
- * @param value - Value to cache.
- * @param ttlMs - Time to live in milliseconds.
- * @returns void
- *
- * @example
- * cache.set("user:1", userData, 5000);
- */
/**
* Updates the value of an existing, non-expired cache entry without resetting its TTL.
- *
- * @param key - Cache key.
- * @param value - New value to store.
- * @returns `true` if the entry existed and was updated, `false` if missing or expired.
*/
update(key: string, value: T): boolean {
const hit = this.store.get(key);
@@ -249,12 +312,18 @@ export class TTLCache {
return false;
}
- hit.value = this.compress(value);
+ const unwrapped = this.unwrap(hit.value);
+ if (!unwrapped) {
+ this.store.delete(key);
+ return false;
+ }
+
+ unwrapped.d = this.compress(value);
+ unwrapped.t = Date.now();
return true;
}
- set(key: string, value: T, ttlMs: number): void {
- //TTLCache.assertValidKey(key);
+ set(key: string, value: T, ttlMs: number, swrMs: number = 0): void {
if (typeof key !== 'string' || key.trim().length === 0) {
throw new TypeError('Cache key cannot be empty');
}
@@ -273,22 +342,19 @@ export class TTLCache {
const oldestKey = this.store.keys().next().value as string | undefined;
if (oldestKey !== undefined) {
this.store.delete(oldestKey);
+ this.stats.evictions++;
}
}
}
+ const compressed = this.compress(value);
+ const envelope = this.wrap(compressed, ttlMs);
+
this.store.delete(key);
- this.store.set(key, { value: this.compress(value), expiresAt: Date.now() + ttlMs });
+ this.store.set(key, { value: envelope, expiresAt: Date.now() + ttlMs + swrMs });
+ this.stats.writes++;
}
- /**
- * Removes all entries from the cache.
- *
- * @returns void
- *
- * @example
- * cache.clear();
- */
clear(): void {
this.store.clear();
}
@@ -320,6 +386,14 @@ export class DistributedCache {
private redisToken: string = '';
private localLocks = new Map>();
+ private stats: Omit = {
+ hits: 0,
+ misses: 0,
+ writes: 0,
+ evictions: 0,
+ swrRefreshes: 0,
+ };
+
constructor(maxSize?: number, cleanupIntervalMs?: number) {
this.localCache = new TTLCache(maxSize, cleanupIntervalMs);
const url = process.env.KV_REST_API_URL || process.env.UPSTASH_REDIS_REST_URL;
@@ -331,17 +405,36 @@ export class DistributedCache {
}
}
- async get(key: string, localTtlMs: number = 5 * 60 * 1000): Promise {
- if (!this.useRedis) {
- return this.localCache.get(key);
- }
+ getStats(): CacheStats {
+ const local = this.localCache.getStats();
+ return {
+ hits: this.stats.hits + local.hits,
+ misses: this.stats.misses + local.misses,
+ writes: this.stats.writes + local.writes,
+ evictions: this.stats.evictions + local.evictions,
+ swrRefreshes: this.stats.swrRefreshes + local.swrRefreshes,
+ size: local.size,
+ };
+ }
+
+ invalidatePattern(pattern: RegExp | string): number {
+ return this.localCache.invalidatePattern(pattern);
+ }
- // Check local L1 cache first for fast in-instance lookups
- const localHit = this.localCache.get(key);
+ async getWithMetadata(
+ key: string,
+ localTtlMs: number = 5 * 60 * 1000
+ ): Promise<{ value: T; expiresAt: number; writtenAt: number } | null> {
+ // Check local L1 cache first
+ const localHit = this.localCache.getWithMetadata(key);
if (localHit !== null) {
return localHit;
}
+ if (!this.useRedis) {
+ return null;
+ }
+
try {
const res = await fetch(`${this.redisUrl}/`, {
method: 'POST',
@@ -358,40 +451,91 @@ export class DistributedCache {
const data = await res.json();
if (!data || data.result === undefined || data.result === null) {
+ this.stats.misses++;
return null;
}
- const parsed = JSON.parse(data.result) as T;
- // Backfill local cache so subsequent requests in this instance are instant
- this.localCache.set(key, parsed, localTtlMs);
- return parsed;
+ const rawResult = JSON.parse(data.result);
+ if (
+ rawResult &&
+ typeof rawResult === 'object' &&
+ 'v' in rawResult &&
+ 'd' in rawResult &&
+ 't' in rawResult &&
+ 'ttl' in rawResult
+ ) {
+ const envelope = rawResult as CacheEnvelope;
+ if (envelope.v === CACHE_VERSION) {
+ const expiresAt = envelope.t + envelope.ttl;
+ this.localCache.set(key, envelope.d as T, envelope.ttl);
+ this.stats.hits++;
+ return {
+ value: envelope.d as T,
+ expiresAt,
+ writtenAt: envelope.t,
+ };
+ } else {
+ // Version mismatch
+ this.stats.misses++;
+ return null;
+ }
+ } else {
+ // Backward compatibility for unversioned legacy items
+ const parsed = rawResult as T;
+ const expiresAt = Date.now() + localTtlMs;
+ this.localCache.set(key, parsed, localTtlMs);
+ this.stats.hits++;
+ return {
+ value: parsed,
+ expiresAt,
+ writtenAt: Date.now() - 60 * 1000,
+ };
+ }
} catch (err) {
logger.error('Cache GET failed', {
component: 'DistributedCache',
key,
error: err,
});
- return this.localCache.get(key);
+ return this.localCache.getWithMetadata(key);
}
}
- async set(key: string, value: T, ttlMs: number): Promise {
+ async get(key: string, localTtlMs: number = 5 * 60 * 1000): Promise {
+ const meta = await this.getWithMetadata(key, localTtlMs);
+ if (!meta) return null;
+ if (Date.now() > meta.expiresAt) {
+ return null;
+ }
+ return meta.value;
+ }
+
+ async set(key: string, value: T, ttlMs: number, swrMs: number = 0): Promise {
// Always update local cache
- this.localCache.set(key, value, ttlMs);
+ this.localCache.set(key, value, ttlMs, swrMs);
+ this.stats.writes++;
if (!this.useRedis) {
return;
}
try {
- const ttlSec = Math.max(1, Math.ceil(ttlMs / 1000));
+ const envelope: CacheEnvelope = {
+ v: CACHE_VERSION,
+ t: Date.now(),
+ ttl: ttlMs,
+ d: value,
+ };
+
+ const physicalTtl = ttlMs + swrMs;
+ const ttlSec = Math.max(1, Math.ceil(physicalTtl / 1000));
const res = await fetch(`${this.redisUrl}/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.redisToken}`,
'Content-Type': 'application/json',
},
- body: JSON.stringify(['SET', key, JSON.stringify(value), 'EX', ttlSec]),
+ body: JSON.stringify(['SET', key, JSON.stringify(envelope), 'EX', ttlSec]),
});
if (!res.ok) {
@@ -460,13 +604,20 @@ export class DistributedCache {
}
try {
+ const envelope: CacheEnvelope = {
+ v: CACHE_VERSION,
+ t: Date.now(),
+ ttl: 60 * 1000, // Guess default TTL
+ d: value,
+ };
+
const res = await fetch(`${this.redisUrl}/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.redisToken}`,
'Content-Type': 'application/json',
},
- body: JSON.stringify(['SET', key, JSON.stringify(value), 'KEEPTTL', 'XX']),
+ body: JSON.stringify(['SET', key, JSON.stringify(envelope), 'KEEPTTL', 'XX']),
});
if (!res.ok) {
@@ -477,6 +628,7 @@ export class DistributedCache {
if (updated) {
this.localCache.update(key, value);
+ this.stats.writes++;
} else {
// Redis no longer has the key, so the L1 value is stale.
this.localCache.delete(key);
@@ -497,17 +649,8 @@ export class DistributedCache {
this.localCache.clear();
}
- /**
- * Atomically increments a numeric counter stored under `key` and returns the new value.
- *
- * When Redis is available, uses EVAL + Lua script for true atomicity.
- * Falls back to the local TTLCache for non-Redis deployments (dev/test).
- *
- * @param key - Cache key holding a numeric counter.
- * @param ttlMs - Time-to-live in milliseconds. Only applied when the key is first created (count == 1).
- * @returns The incremented counter value.
- */
async incr(key: string, ttlMs: number): Promise {
+ this.stats.writes++;
if (!this.useRedis) {
const current = (this.localCache.get(key) as unknown as number) || 0;
const next = current + 1;
@@ -581,34 +724,68 @@ return c`;
* @param ttlMs - Cache expiration time in milliseconds.
* @param shouldFetch - Optional predicate that forces refresh even on cache hits.
* @param lockConfig - Optional distributed lock tuning.
+ * @param swrMs - Optional stale-while-revalidate duration in milliseconds.
*/
async getOrSet(
key: string,
loadFn: (cached: T | null) => Promise,
ttlMs: number,
shouldFetch?: (cached: T) => boolean,
- lockConfig?: LockConfig
+ lockConfig?: LockConfig,
+ swrMs?: number
): Promise {
- // Join an existing in-flight request before any async operation to avoid
- // concurrent loadFn execution for the same key.
+ // Join an existing in-flight request before any async operation
const existing = this.localLocks.get(key);
if (existing) return existing;
- // Attempt to retrieve an existing value before triggering a refresh.
- const cached = await this.get(key, ttlMs);
+ // Retrieve cached item with metadata
+ const cachedMeta = await this.getWithMetadata(key, ttlMs);
+
+ if (cachedMeta !== null) {
+ const forceFetch = shouldFetch && shouldFetch(cachedMeta.value);
+ if (!forceFetch) {
+ if (Date.now() < cachedMeta.expiresAt) {
+ return cachedMeta.value;
+ }
- if (cached !== null && (!shouldFetch || !shouldFetch(cached))) {
- return cached;
+ // Check if inside SWR window
+ if (swrMs && Date.now() < cachedMeta.expiresAt + swrMs) {
+ this.stats.swrRefreshes++;
+ this.localCache.incrementSwrRefreshes();
+ // Trigger asynchronous background refresh
+ this.executeAndLockBg(key, loadFn, ttlMs, cachedMeta.value, lockConfig, swrMs);
+ // Return stale cached value immediately
+ return cachedMeta.value;
+ }
+ }
}
- // Double-check local locks after the await in case another call interleaved.
const pendingLocal = this.localLocks.get(key);
if (pendingLocal) return pendingLocal;
+ const promise = this.executeAndLockBg(
+ key,
+ loadFn,
+ ttlMs,
+ cachedMeta ? cachedMeta.value : null,
+ lockConfig,
+ swrMs
+ );
+ return promise;
+ }
+
+ private async executeAndLockBg(
+ key: string,
+ loadFn: (cached: T | null) => Promise,
+ ttlMs: number,
+ cached: T | null,
+ lockConfig?: LockConfig,
+ swrMs: number = 0
+ ): Promise {
const executeAndLock = async () => {
if (!this.useRedis) {
const data = await loadFn(cached);
- await this.set(key, data, ttlMs);
+ await this.set(key, data, ttlMs, swrMs);
return data;
}
@@ -623,8 +800,6 @@ return c`;
const start = Date.now();
let attempt = 0;
- // Only DEL the lock if the stored token still matches ours, preventing
- // accidental deletion of a lock acquired by another instance after ours expired.
const luaRelease = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
@@ -664,8 +839,6 @@ return c`;
let acquired = false;
try {
- // NX: acquire only if lock doesn't already exist.
- // PX: auto-expire lock to avoid deadlocks.
const lockRes = await fetch(`${this.redisUrl}/`, {
method: 'POST',
headers: {
@@ -682,14 +855,13 @@ return c`;
throw new Error(`Redis lock HTTP error: ${lockRes.status}`);
}
} catch (err) {
- // Redis network error during locking. Fallback to direct execution.
logger.error('Cache lock failed', {
component: 'DistributedCache',
key,
error: err,
});
const fallbackData = await loadFn(cached);
- await this.set(key, fallbackData, ttlMs);
+ await this.set(key, fallbackData, ttlMs, swrMs);
return fallbackData;
}
@@ -697,18 +869,12 @@ return c`;
let extensionTimer: ReturnType | null = null;
if (enableLockExtension) {
- // Heartbeat fires at 60% of lockTtlMs so there is always time before expiry.
- // When lockTtlMs is small (<1667ms), clamp to lockTtlMs/2 but with at least
- // 100ms of headroom so the heartbeat always fires before the lock expires.
const rawInterval = Math.floor(lockTtlMs * 0.6);
const minInterval = Math.min(1000, Math.max(100, lockTtlMs - 100));
const extensionInterval = Math.max(minInterval, rawInterval);
extensionTimer = setInterval(async () => {
try {
- // Atomically extend only if we still own the lock (token matches).
- // Using SET XX without a token check would let us extend a lock that
- // another instance acquired after ours expired — do not use SET XX alone.
const luaExtend = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
redis.call("PEXPIRE", KEYS[1], ARGV[2])
@@ -730,7 +896,7 @@ return c`;
]),
});
} catch {
- // Silently ignore extension failures — the lock will expire naturally.
+ // Ignore extension failures
}
}, extensionInterval);
if (typeof extensionTimer === 'object' && typeof extensionTimer.unref === 'function') {
@@ -740,7 +906,7 @@ return c`;
try {
const freshData = await loadFn(cached);
- await this.set(key, freshData, ttlMs);
+ await this.set(key, freshData, ttlMs, swrMs);
return freshData;
} finally {
if (extensionTimer) clearInterval(extensionTimer);
@@ -748,8 +914,6 @@ return c`;
}
}
- // Exponential backoff with jitter to prevent thundering herd
- // when multiple instances contend for the same lock.
const baseBackoff = Math.min(BASE_POLL_MS * 2 ** attempt, MAX_POLL_MS);
const jitter = 0.5 + Math.random() * 0.5;
const backoffMs = Math.round(baseBackoff * jitter);
@@ -757,14 +921,13 @@ return c`;
attempt++;
const doubleCheck = await this.get(key, ttlMs);
- if (doubleCheck !== null && (!shouldFetch || !shouldFetch(doubleCheck))) {
+ if (doubleCheck !== null) {
return doubleCheck;
}
}
- // Timed out waiting for lock. Fallback to direct execution.
const finalFallback = await loadFn(cached);
- await this.set(key, finalFallback, ttlMs);
+ await this.set(key, finalFallback, ttlMs, swrMs);
return finalFallback;
};
diff --git a/lib/github.msw.test.ts b/lib/github.msw.test.ts
index 39923728e..2fd4c11f3 100644
--- a/lib/github.msw.test.ts
+++ b/lib/github.msw.test.ts
@@ -304,7 +304,7 @@ describe('MSW: error handling', () => {
await expect(fetchUserProfile('octocat')).rejects.toThrow();
});
- it('handles network errors gracefully', async () => {
+ it('handles network errors gracefully by falling back to mock profile', async () => {
globalThis.fetch = (() => {
throw new TypeError('Failed to fetch');
}) as typeof fetch;
@@ -312,6 +312,8 @@ describe('MSW: error handling', () => {
process.env.GITHUB_PAT = 'test-token';
delete process.env.GITHUB_TOKEN;
clearGitHubApiCacheForTests();
- await expect(fetchUserProfile('octocat')).rejects.toThrow();
+ const result = await fetchUserProfile('octocat');
+ expect(result.isOfflineFallback).toBe(true);
+ expect(result.login).toBe('octocat');
});
});
diff --git a/lib/github.test.ts b/lib/github.test.ts
index d99b73b9b..278a083a5 100644
--- a/lib/github.test.ts
+++ b/lib/github.test.ts
@@ -283,10 +283,12 @@ describe('fetchGitHubContributions', () => {
);
});
- it('throws when fetch itself rejects due to a network failure', async () => {
+ it('falls back to empty calendar when fetch itself rejects due to a network failure', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Failed to fetch'));
- await expect(fetchGitHubContributions('octocat')).rejects.toThrow('Failed to fetch');
+ const result = await fetchGitHubContributions('octocat');
+ expect(result.calendar.totalContributions).toBe(0);
+ expect(result.isOfflineFallback).toBe(true);
});
it('throws the first GraphQL error when the API returns an errors array', async () => {
@@ -352,15 +354,16 @@ describe('fetchGitHubContributions', () => {
expect(fetch).toHaveBeenCalledTimes(2);
});
- it('throws after exhausting all retries on repeated body-level RATE_LIMITED errors', async () => {
+ it('falls back to empty calendar after exhausting all retries on repeated body-level RATE_LIMITED errors', async () => {
vi.mocked(fetch).mockResolvedValue(
mockResponse({ errors: [{ type: 'RATE_LIMITED', message: 'API rate limit exceeded' }] })
);
const promise = fetchGitHubContributions('octocat');
- const assertion = expect(promise).rejects.toThrow('API Rate Limit Exceeded');
await vi.advanceTimersByTimeAsync(3500);
- await assertion;
+ const result = await promise;
+ expect(result.calendar.totalContributions).toBe(0);
+ expect(result.isOfflineFallback).toBe(true);
expect(fetch).toHaveBeenCalledTimes(4);
});
});
@@ -790,15 +793,15 @@ describe('fetchContributedRepos', () => {
await assertion;
});
- it('throws on a rate-limited GraphQL 200 response instead of returning []', async () => {
+ it('falls back to [] on a rate-limited GraphQL 200 response', async () => {
vi.mocked(fetch).mockResolvedValue(
mockResponse({ errors: [{ type: 'RATE_LIMITED', message: 'API rate limit exceeded' }] })
);
const promise = fetchContributedRepos('octocat');
- const assertion = expect(promise).rejects.toThrow('API Rate Limit Exceeded');
await vi.advanceTimersByTimeAsync(3500);
- await assertion;
+ const result = await promise;
+ expect(result).toEqual([]);
});
it('does not cache the failure: a later call refetches and can succeed', async () => {
diff --git a/lib/github.ts b/lib/github.ts
index 67d3cefb6..814e09981 100644
--- a/lib/github.ts
+++ b/lib/github.ts
@@ -31,6 +31,48 @@ interface GitHubRepo {
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 500;
const MAX_RETRY_DELAY_MS = 5000;
+
+export function getJitteredBackoff(attempt: number): number {
+ const base = BASE_DELAY_MS * Math.pow(2, attempt);
+ const jitter = 0.5 + Math.random() * 0.5;
+ return Math.round(base * jitter);
+}
+
+export function shouldFallbackOnError(err: unknown): boolean {
+ if (!err) return false;
+ const msg = err instanceof Error ? err.message : String(err);
+
+ if (
+ msg.includes('not found') ||
+ msg.includes('Not Found') ||
+ msg.includes('401') ||
+ msg.includes('Unauthorized') ||
+ msg.includes('token') ||
+ msg.includes('PAT') ||
+ msg.includes('Authorization') ||
+ msg.includes('status 500') ||
+ msg.includes('error: 500') ||
+ msg.includes('Bad credentials')
+ ) {
+ return false;
+ }
+
+ if (
+ msg.includes('fetch') ||
+ msg.includes('timeout') ||
+ msg.includes('timed out') ||
+ msg.includes('AbortError') ||
+ msg.includes('rate limit') ||
+ msg.includes('RATE_LIMITED') ||
+ msg.includes('Rate Limit') ||
+ err instanceof RateLimitError
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
const GRAPHQL_TIMEOUT_MS = 8000; // 8s for GraphQL endpoint
const REST_TIMEOUT_MS = 5000; // 5s for REST endpoints
const ORG_MEMBER_LIMIT = 100;
@@ -152,7 +194,7 @@ export async function fetchWithRetry(
}
throw fetchError;
}
- const delay = BASE_DELAY_MS * Math.pow(2, attempt);
+ const delay = getJitteredBackoff(attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, options, attempt + 1, timeoutMs, userToken);
}
@@ -196,7 +238,7 @@ export async function fetchWithRetry(
}
// Retry immediately with the next token if available
if (attempt < MAX_RETRIES && tokens.length > 1) {
- const delay = BASE_DELAY_MS * Math.pow(2, attempt);
+ const delay = getJitteredBackoff(attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, options, attempt + 1, timeoutMs, userToken);
}
@@ -227,7 +269,7 @@ export async function fetchWithRetry(
if (attempt >= MAX_RETRIES) return res;
- let delay = BASE_DELAY_MS * Math.pow(2, attempt);
+ let delay = getJitteredBackoff(attempt);
if (retryAfter) {
const parsed = parseInt(retryAfter, 10);
if (!Number.isNaN(parsed) && String(parsed) === retryAfter) {
@@ -257,7 +299,7 @@ export async function fetchWithRetry(
const shouldRetry = res.status >= 500;
if (!shouldRetry || attempt >= MAX_RETRIES) return res;
- const delay = BASE_DELAY_MS * Math.pow(2, attempt);
+ const delay = getJitteredBackoff(attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, options, attempt + 1, timeoutMs, userToken);
}
@@ -337,9 +379,7 @@ async function fetchGraphQLWithRetry(
if (!isBodyRateLimited) return res;
- const delay = BASE_DELAY_MS * Math.pow(2, attempt);
- if (delay > MAX_RETRY_DELAY_MS) return res;
-
+ const delay = getJitteredBackoff(attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchGraphQLWithRetry(url, options, attempt + 1, timeoutMs, userToken);
}
@@ -467,6 +507,7 @@ interface GitHubUserProfile {
location: string | null;
type?: string;
plan?: { name?: string } | null;
+ isOfflineFallback?: boolean;
}
function sanitizeUserProfile(profile: GitHubUserProfile): GitHubUserProfile {
@@ -663,6 +704,21 @@ export function getCircuitTelemetry() {
const FETCH_TIMEOUT_MS = 4000;
const activeContributionsPromises = new Map>();
+export function getMockContributions(): ExtendedContributionData {
+ return {
+ calendar: {
+ totalContributions: 0,
+ weeks: [],
+ lastSyncedAt: new Date().toISOString(),
+ },
+ repoContributions: [],
+ totalPRs: 0,
+ totalIssues: 0,
+ totalReviews: 0,
+ isOfflineFallback: true,
+ };
+}
+
export async function fetchGitHubContributions(
username: string,
options: FetchOptions = {}
@@ -733,7 +789,26 @@ export async function fetchGitHubContributions(
if (options.signal) {
if (options.bypassCache || options.forceRefresh) {
- return await loadWithTimeout();
+ try {
+ return await loadWithTimeout();
+ } catch (err: unknown) {
+ if (shouldFallbackOnError(err)) {
+ const staleData = await contributionsCache.get(key);
+ if (staleData) {
+ logger.warn('GitHub API fetch failed, falling back to stale cache', {
+ component: 'GitHub API',
+ username,
+ error: err,
+ });
+ return {
+ ...staleData,
+ isOfflineFallback: true,
+ };
+ }
+ return getMockContributions(username);
+ }
+ throw err;
+ }
}
const cached = await contributionsCache.get(key);
if (cached !== null && !shouldFetch(cached)) {
@@ -742,17 +817,20 @@ export async function fetchGitHubContributions(
try {
return await loadWithTimeout();
} catch (err: unknown) {
- const staleData = await contributionsCache.get(key);
- if (staleData) {
- logger.warn('GitHub API fetch failed, falling back to stale cache', {
- component: 'GitHub API',
- username,
- error: err,
- });
- return {
- ...staleData,
- isOfflineFallback: true,
- };
+ if (shouldFallbackOnError(err)) {
+ const staleData = await contributionsCache.get(key);
+ if (staleData) {
+ logger.warn('GitHub API fetch failed, falling back to stale cache', {
+ component: 'GitHub API',
+ username,
+ error: err,
+ });
+ return {
+ ...staleData,
+ isOfflineFallback: true,
+ };
+ }
+ return getMockContributions(username);
}
throw err;
}
@@ -766,16 +844,19 @@ export async function fetchGitHubContributions(
}
return result;
} catch (err: unknown) {
- const staleData = await contributionsCache.get(key);
- if (staleData) {
- console.warn(
- `[GitHub API] Fetch failed or timed out for "${username}", falling back to stale cache:`,
- err
- );
- return {
- ...staleData,
- isOfflineFallback: true,
- };
+ if (shouldFallbackOnError(err)) {
+ const staleData = await contributionsCache.get(key);
+ if (staleData) {
+ console.warn(
+ `[GitHub API] Fetch failed or timed out for "${username}", falling back to stale cache:`,
+ err
+ );
+ return {
+ ...staleData,
+ isOfflineFallback: true,
+ };
+ }
+ return getMockContributions(username);
}
throw err;
}
@@ -784,17 +865,20 @@ export async function fetchGitHubContributions(
try {
return await contributionsCache.getOrSet(key, coalescedLoad, LONG_CACHE_TTL, shouldFetch);
} catch (err: unknown) {
- const staleData = await contributionsCache.get(key);
- if (staleData) {
- logger.warn('GitHub API fetch failed, falling back to stale cache', {
- component: 'GitHub API',
- username,
- error: err,
- });
- return {
- ...staleData,
- isOfflineFallback: true,
- };
+ if (shouldFallbackOnError(err)) {
+ const staleData = await contributionsCache.get(key);
+ if (staleData) {
+ logger.warn('GitHub API fetch failed, falling back to stale cache', {
+ component: 'GitHub API',
+ username,
+ error: err,
+ });
+ return {
+ ...staleData,
+ isOfflineFallback: true,
+ };
+ }
+ return getMockContributions(username);
}
throw err;
}
@@ -958,6 +1042,23 @@ async function fetchContributionsUncached(
};
}
+export function getMockProfile(username: string): GitHubUserProfile {
+ return {
+ login: username,
+ name: username,
+ avatar_url: `https://github.com/${username}.png`,
+ public_repos: 0,
+ followers: 0,
+ following: 0,
+ created_at: new Date().toISOString(),
+ bio: 'Profile currently unavailable (offline fallback)',
+ location: null,
+ type: 'User',
+ plan: null,
+ isOfflineFallback: true,
+ };
+}
+
export async function fetchUserProfile(
username: string,
options: FetchOptions = {}
@@ -969,8 +1070,41 @@ export async function fetchUserProfile(
return fetchProfileUncached(encodedUsername, key, options);
};
- if (options.bypassCache || options.forceRefresh) return load();
- return profileCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS);
+ if (options.bypassCache || options.forceRefresh) {
+ try {
+ return await load();
+ } catch (err) {
+ if (shouldFallbackOnError(err)) {
+ const stale = await profileCache.get(key);
+ if (stale) {
+ logger.warn('GitHub API profile fetch failed, returning stale cache data', {
+ username,
+ error: err,
+ });
+ return { ...stale, isOfflineFallback: true };
+ }
+ return getMockProfile(username);
+ }
+ throw err;
+ }
+ }
+
+ try {
+ return await profileCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS);
+ } catch (err) {
+ if (shouldFallbackOnError(err)) {
+ const stale = await profileCache.get(key);
+ if (stale) {
+ logger.warn('GitHub API profile fetch failed, returning stale cache data', {
+ username,
+ error: err,
+ });
+ return { ...stale, isOfflineFallback: true };
+ }
+ return getMockProfile(username);
+ }
+ throw err;
+ }
}
async function fetchProfileUncached(
@@ -1020,8 +1154,41 @@ export async function fetchUserRepos(
return fetchReposUncached(encodedUsername, key, options);
};
- if (options.bypassCache || options.forceRefresh) return load();
- return reposCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS);
+ if (options.bypassCache || options.forceRefresh) {
+ try {
+ return await load();
+ } catch (err) {
+ if (shouldFallbackOnError(err)) {
+ const stale = await reposCache.get(key);
+ if (stale) {
+ logger.warn('GitHub API repos fetch failed, returning stale cache data', {
+ username,
+ error: err,
+ });
+ return stale;
+ }
+ return [];
+ }
+ throw err;
+ }
+ }
+
+ try {
+ return await reposCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS);
+ } catch (err) {
+ if (shouldFallbackOnError(err)) {
+ const stale = await reposCache.get(key);
+ if (stale) {
+ logger.warn('GitHub API repos fetch failed, returning stale cache data', {
+ username,
+ error: err,
+ });
+ return stale;
+ }
+ return [];
+ }
+ throw err;
+ }
}
async function fetchReposUncached(
@@ -1439,13 +1606,45 @@ export async function fetchContributedRepos(
return data?.data?.user?.repositoriesContributedTo?.nodes || [];
};
- if (options.bypassCache) return load();
- if (options.forceRefresh) {
- const fresh = await load();
- await contributedReposCache.set(key, fresh, GITHUB_CACHE_TTL_MS);
- return fresh;
+ if (options.bypassCache || options.forceRefresh) {
+ try {
+ const fresh = await load();
+ if (options.forceRefresh) {
+ await contributedReposCache.set(key, fresh, GITHUB_CACHE_TTL_MS);
+ }
+ return fresh;
+ } catch (err) {
+ if (shouldFallbackOnError(err)) {
+ const stale = await contributedReposCache.get(key);
+ if (stale) {
+ logger.warn('GitHub API contributed repos fetch failed, returning stale cache data', {
+ username,
+ error: err,
+ });
+ return stale;
+ }
+ return [];
+ }
+ throw err;
+ }
+ }
+
+ try {
+ return await contributedReposCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS);
+ } catch (err) {
+ if (shouldFallbackOnError(err)) {
+ const stale = await contributedReposCache.get(key);
+ if (stale) {
+ logger.warn('GitHub API contributed repos fetch failed, returning stale cache data', {
+ username,
+ error: err,
+ });
+ return stale;
+ }
+ return [];
+ }
+ throw err;
}
- return contributedReposCache.getOrSet(key, load, GITHUB_CACHE_TTL_MS);
}
export interface DeveloperScoreInput {
diff --git a/package-lock.json b/package-lock.json
index 947c7bb3f..8f2a0a1e4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -224,7 +224,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -502,7 +501,6 @@
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -616,7 +614,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -665,7 +662,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -746,7 +742,6 @@
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
@@ -758,7 +753,6 @@
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -3410,7 +3404,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -3709,7 +3702,6 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3720,7 +3712,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3797,7 +3788,6 @@
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
@@ -4464,7 +4454,6 @@
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.8",
@@ -4594,7 +4583,6 @@
"integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/utils": "4.1.8",
"fflate": "^0.8.2",
@@ -4706,7 +4694,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5206,7 +5193,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5644,8 +5630,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
@@ -5800,7 +5785,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -6458,7 +6442,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6660,7 +6643,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7624,8 +7606,7 @@
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
- "license": "Standard 'no charge' license: https://gsap.com/standard-license.",
- "peer": true
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-bigints": {
"version": "1.1.0",
@@ -7863,7 +7844,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -9873,7 +9853,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz",
"integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@next/env": "16.2.9",
"@swc/helpers": "0.5.15",
@@ -10541,7 +10520,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -10584,7 +10562,6 @@
"integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -10605,7 +10582,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -10797,7 +10773,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10807,7 +10782,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -10871,8 +10845,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-kapsule": {
"version": "2.5.7",
@@ -10907,7 +10880,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -10995,8 +10967,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -12570,7 +12541,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -12708,7 +12678,6 @@
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
@@ -12848,7 +12817,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13066,7 +13034,6 @@
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -13158,7 +13125,6 @@
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
@@ -13553,7 +13519,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/src/utils/__tests__/graphqlSync.test.ts b/src/utils/__tests__/graphqlSync.test.ts
index f58d1a992..8a7bdf789 100644
--- a/src/utils/__tests__/graphqlSync.test.ts
+++ b/src/utils/__tests__/graphqlSync.test.ts
@@ -48,9 +48,9 @@ describe('GraphQL Syncing Utility Integration Tests', () => {
}),
});
- // Verifies your utility safely surfaces network errors instead of an unhandled crash
- await expect(
- fetchGitHubContributions('attardekhushi78-cpu', { bypassCache: true })
- ).rejects.toThrow();
+ // Verifies your utility gracefully falls back to empty calendar instead of an unhandled crash
+ const result = await fetchGitHubContributions('attardekhushi78-cpu', { bypassCache: true });
+ expect(result.calendar.totalContributions).toBe(0);
+ expect(result.isOfflineFallback).toBe(true);
});
});
diff --git a/tests/cache-advanced.test.ts b/tests/cache-advanced.test.ts
new file mode 100644
index 000000000..f028dff87
--- /dev/null
+++ b/tests/cache-advanced.test.ts
@@ -0,0 +1,123 @@
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { TTLCache, DistributedCache } from '@/lib/cache';
+
+describe('Advanced Cache Management (Phase 2)', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe('Cache Versioning', () => {
+ it('invalidates key on cache version mismatch', () => {
+ const cache = new TTLCache();
+ cache.set('user', 'octocat', 10_000);
+
+ // Mutate stored envelope version to mock version mismatch
+
+ const internalStore = (cache as unknown as { store: Map })
+ .store;
+ const entry = internalStore.get('user');
+ entry.value.v = 'v0'; // older mismatching version
+
+ expect(cache.get('user')).toBeNull();
+ expect(cache.has('user')).toBe(false);
+ cache.destroy();
+ });
+
+ it('retains backward compatibility for unversioned legacy items', () => {
+ const cache = new TTLCache();
+
+ const internalStore = (
+ cache as unknown as { store: Map }
+ ).store;
+ // Inject unversioned legacy item directly
+ internalStore.set('legacy', {
+ value: 'legacy-data',
+ expiresAt: Date.now() + 10_000,
+ });
+
+ expect(cache.get('legacy')).toBe('legacy-data');
+ cache.destroy();
+ });
+ });
+
+ describe('Pattern-based Cleanup', () => {
+ it('deletes keys matching a pattern while keeping others', () => {
+ const cache = new TTLCache();
+ cache.set('user:123', 'john', 10_000);
+ cache.set('user:456', 'alice', 10_000);
+ cache.set('stats:789', 'data', 10_000);
+
+ const deletedCount = cache.invalidatePattern(/^user:/);
+ expect(deletedCount).toBe(2);
+ expect(cache.get('user:123')).toBeNull();
+ expect(cache.get('user:456')).toBeNull();
+ expect(cache.get('stats:789')).toBe('data');
+ cache.destroy();
+ });
+ });
+
+ describe('Stale-While-Revalidate (SWR)', () => {
+ it('returns stale cache immediately and triggers background fetch in SWR window', async () => {
+ const cache = new DistributedCache();
+ let loadCount = 0;
+ const loadFn = async () => {
+ loadCount++;
+ return `fresh-data-${loadCount}`;
+ };
+
+ // Set initial value with TTL 5s and SWR window 10s
+ await cache.set('swr-test', 'initial-data', 5000, 10000);
+
+ // Advance time by 6s (stale, but within SWR window of 10s)
+ vi.advanceTimersByTime(6000);
+
+ // Call getOrSet with swrMs = 10000
+ const result = await cache.getOrSet('swr-test', loadFn, 5000, undefined, undefined, 10000);
+
+ // Should return the stale value immediately
+ expect(result).toBe('initial-data');
+ expect(loadCount).toBe(1); // background loadFn triggered
+
+ // Resolve background execution
+
+ const bgPromise = (
+ cache as unknown as { localLocks: Map> }
+ ).localLocks.get('swr-test');
+ if (bgPromise) await bgPromise;
+
+ // Retrieve again: should now have the fresh value from background loadFn
+ const updatedResult = await cache.get('swr-test');
+ expect(updatedResult).toBe('fresh-data-1');
+ cache.destroy();
+ });
+ });
+
+ describe('Cache Instrumentation & Stats', () => {
+ it('accurately counts hits, misses, writes, and evictions', () => {
+ const cache = new TTLCache(2); // maxSize = 2
+
+ // Write stats
+ cache.set('a', 1, 10_000);
+ cache.set('b', 2, 10_000);
+ expect(cache.getStats().writes).toBe(2);
+
+ // Hit stats
+ cache.get('a');
+ expect(cache.getStats().hits).toBe(1);
+
+ // Miss stats
+ cache.get('missing');
+ expect(cache.getStats().misses).toBe(1);
+
+ // Eviction stats
+ cache.set('c', 3, 10_000); // evicts 'a' or 'b'
+ expect(cache.getStats().evictions).toBe(1);
+ cache.destroy();
+ });
+ });
+});
diff --git a/tests/github-recovery.test.ts b/tests/github-recovery.test.ts
new file mode 100644
index 000000000..0a5bf0ad3
--- /dev/null
+++ b/tests/github-recovery.test.ts
@@ -0,0 +1,149 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import {
+ fetchGitHubContributions,
+ fetchUserProfile,
+ fetchUserRepos,
+ getJitteredBackoff,
+ clearGitHubApiCacheForTests,
+} from '../lib/github';
+
+function mockResponse(body: unknown, status = 200, headers: Record = {}): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json', ...headers },
+ });
+}
+
+describe('GitHub API Failure Recovery (Phase 3)', () => {
+ beforeEach(() => {
+ clearGitHubApiCacheForTests();
+ vi.spyOn(global, 'fetch');
+ process.env.GITHUB_PAT = 'test-token';
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ clearGitHubApiCacheForTests();
+ });
+
+ describe('Jittered Backoff Calculations', () => {
+ it('verifies that getJitteredBackoff returns values within the correct bounds', () => {
+ // Attempt 0: base delay = 500ms. Bounds: [250, 500]
+ for (let i = 0; i < 100; i++) {
+ const delay = getJitteredBackoff(0);
+ expect(delay).toBeGreaterThanOrEqual(250);
+ expect(delay).toBeLessThanOrEqual(500);
+ }
+
+ // Attempt 1: base delay = 1000ms. Bounds: [500, 1000]
+ for (let i = 0; i < 100; i++) {
+ const delay = getJitteredBackoff(1);
+ expect(delay).toBeGreaterThanOrEqual(500);
+ expect(delay).toBeLessThanOrEqual(1000);
+ }
+ });
+ });
+
+ describe('fetchUserProfile Fallback', () => {
+ it('falls back to stale cache data on network failure', async () => {
+ // 1. Populate cache with a successful fetch first
+ vi.mocked(fetch).mockResolvedValueOnce(
+ mockResponse({
+ login: 'octocat',
+ name: 'The Octocat',
+ avatar_url: 'https://github.com/octocat.png',
+ public_repos: 8,
+ followers: 20,
+ following: 9,
+ created_at: '2011-01-20T09:00:00Z',
+ })
+ );
+ const firstResult = await fetchUserProfile('octocat');
+ expect(firstResult.login).toBe('octocat');
+
+ // 2. Mock a network failure for the second fetch
+ vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch'));
+
+ // 3. Force refresh or bypass cache to trigger error path, should get stale data with isOfflineFallback
+ const result = await fetchUserProfile('octocat', { forceRefresh: true });
+ expect(result.login).toBe('octocat');
+ expect(result.isOfflineFallback).toBe(true);
+ });
+
+ it('returns a mock placeholder profile if cache is empty on network failure', async () => {
+ // Mock network failure immediately when cache is empty
+ vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch'));
+
+ const result = await fetchUserProfile('some-new-user');
+ expect(result.login).toBe('some-new-user');
+ expect(result.isOfflineFallback).toBe(true);
+ expect(result.bio).toContain('offline fallback');
+ });
+ });
+
+ describe('fetchUserRepos Fallback', () => {
+ it('falls back to stale cache data on network failure', async () => {
+ // 1. Populate cache
+ vi.mocked(fetch).mockResolvedValueOnce(
+ mockResponse([{ name: 'repo-1', stargazers_count: 5, language: 'TypeScript' }])
+ );
+ const firstResult = await fetchUserRepos('octocat');
+ expect(firstResult).toHaveLength(1);
+
+ // 2. Mock network failure
+ vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch'));
+
+ // 3. Trigger error path
+ const result = await fetchUserRepos('octocat', { forceRefresh: true });
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('repo-1');
+ });
+
+ it('returns empty array if cache is empty on network failure', async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch'));
+
+ const result = await fetchUserRepos('some-new-user');
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('fetchGitHubContributions Fallback', () => {
+ it('falls back to stale cache data on network failure', async () => {
+ // 1. Populate cache
+ const mockCalendar = {
+ totalContributions: 10,
+ weeks: [],
+ };
+ vi.mocked(fetch).mockResolvedValueOnce(
+ mockResponse({
+ data: {
+ user: {
+ contributionsCollection: {
+ contributionCalendar: mockCalendar,
+ commitContributionsByRepository: [],
+ },
+ },
+ },
+ })
+ );
+ const firstResult = await fetchGitHubContributions('octocat');
+ expect(firstResult.calendar.totalContributions).toBe(10);
+
+ // 2. Mock network failure
+ vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch'));
+
+ // 3. Trigger error path
+ const result = await fetchGitHubContributions('octocat', { forceRefresh: true });
+ expect(result.calendar.totalContributions).toBe(10);
+ expect(result.isOfflineFallback).toBe(true);
+ });
+
+ it('returns empty mock contributions calendar if cache is empty on network failure', async () => {
+ vi.mocked(fetch).mockRejectedValue(new Error('TypeError: Failed to fetch'));
+
+ const result = await fetchGitHubContributions('some-new-user');
+ expect(result.calendar.totalContributions).toBe(0);
+ expect(result.isOfflineFallback).toBe(true);
+ });
+ });
+});
diff --git a/tests/pwa/offline-page.test.tsx b/tests/pwa/offline-page.test.tsx
new file mode 100644
index 000000000..1773a2560
--- /dev/null
+++ b/tests/pwa/offline-page.test.tsx
@@ -0,0 +1,40 @@
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import React from 'react';
+import OfflineFallback from '@/components/pwa/OfflineFallback';
+
+// Mock location.reload
+const reloadMock = vi.fn();
+Object.defineProperty(window, 'location', {
+ value: { reload: reloadMock },
+ writable: true,
+});
+
+describe('OfflineFallback Component', () => {
+ beforeEach(() => {
+ reloadMock.mockClear();
+ });
+
+ it('renders connection lost header, description, and button', () => {
+ render();
+
+ expect(screen.getByText('Connection Lost')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'You are currently offline. Check your internet connection and try refreshing the page.'
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
+ });
+
+ it('triggers page reload when try again button is clicked', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: /try again/i });
+ fireEvent.click(button);
+
+ expect(reloadMock).toHaveBeenCalledTimes(1);
+ expect(button).toBeDisabled();
+ });
+});