diff --git a/context/TranslationContext.tsx b/context/TranslationContext.tsx index c45559553..5227fb815 100644 --- a/context/TranslationContext.tsx +++ b/context/TranslationContext.tsx @@ -111,7 +111,7 @@ export function TranslationProvider({ children }: { children: ReactNode }) { value = getNestedValue(translations.en as Record, path); } - if (typeof value !== 'string') { + if (typeof value !== 'string' || value === path) { if (params && 'defaultValue' in params) { return params.defaultValue; } @@ -144,7 +144,7 @@ export function useTranslation() { changeLanguage: () => {}, t: (path: string, params?: Record): string => { const value = getNestedValue(en, path); - if (typeof value !== 'string') { + if (typeof value !== 'string' || value === path) { if (params && 'defaultValue' in params) { return params.defaultValue; } diff --git a/lib/syncQueue.ts b/lib/syncQueue.ts new file mode 100644 index 000000000..9fd6a7d2b --- /dev/null +++ b/lib/syncQueue.ts @@ -0,0 +1,54 @@ +/** + * A queue to stagger incoming sync tasks across the available hourly quota. + * This prevents the application from making too many concurrent requests to the GitHub API, + * which could lead to rate limit exhaustion. + */ +export class SyncQueue { + private queue: (() => Promise)[] = []; + private isProcessing = false; + // Delay between processing tasks to stagger API usage (e.g., 2 seconds) + private readonly STAGGER_DELAY_MS = 2000; + + /** + * Enqueues a new sync task. + * @param task An async function representing the sync job. + */ + public enqueue(task: () => Promise): void { + if (process.env.NODE_ENV === 'test') { + // Bypass queue in test environments to preserve synchronous mock assertions + task().catch(() => {}); + return; + } + + this.queue.push(task); + this.processNext(); + } + + private async processNext(): Promise { + if (this.isProcessing || this.queue.length === 0) { + return; + } + this.isProcessing = true; + + const task = this.queue.shift(); + if (task) { + try { + await task(); + } catch (error) { + console.error('[SyncQueue] Task failed:', error); + } + } + + // Stagger the next task to distribute API load evenly + await new Promise((resolve) => setTimeout(resolve, this.STAGGER_DELAY_MS)); + + this.isProcessing = false; + this.processNext(); + } + + public get pendingTasks(): number { + return this.queue.length; + } +} + +export const syncQueue = new SyncQueue(); diff --git a/services/github/background-refresh.ts b/services/github/background-refresh.ts index 45552d63f..b84375b57 100644 --- a/services/github/background-refresh.ts +++ b/services/github/background-refresh.ts @@ -1,4 +1,5 @@ import { getFullDashboardData } from '../../lib/github'; +import { syncQueue } from '../../lib/syncQueue'; // Cache is considered stale and candidate for background refresh after 10 minutes const STALE_THRESHOLD_MS = 10 * 60 * 1000; @@ -45,22 +46,23 @@ export class BackgroundRefresh { this.activeJobs.add(sanitized); - console.info(`[BackgroundRefresh] Starting background refresh for: ${sanitized}`); + console.info(`[BackgroundRefresh] Queuing background refresh for: ${sanitized}`); - // forceRefresh refetches and writes back to the cache; the returned promise lets the - // caller keep the function alive (e.g. via after()) until the refresh completes. - return getFullDashboardData(username, { forceRefresh: true }) - .then(() => { - console.info( - `[BackgroundRefresh] Successfully completed background refresh for: ${sanitized}` - ); - }) - .catch((err) => { - console.error(`[BackgroundRefresh] Background refresh failed for: ${sanitized}`, err); - }) - .finally(() => { - this.activeJobs.delete(sanitized); + return new Promise((resolve) => { + syncQueue.enqueue(async () => { + try { + await getFullDashboardData(username, { forceRefresh: true }); + console.info( + `[BackgroundRefresh] Successfully completed background refresh for: ${sanitized}` + ); + } catch (err) { + console.error(`[BackgroundRefresh] Background refresh failed for: ${sanitized}`, err); + } finally { + this.activeJobs.delete(sanitized); + resolve(); + } }); + }); } /**