From f840acd2fb59519a8432bb28e961c4accc323490 Mon Sep 17 00:00:00 2001 From: Vardhan Date: Sat, 6 Jun 2026 12:03:22 +0530 Subject: [PATCH 1/5] feat(result): add resetAt to AppError --- src/lib/result.test.ts | 15 +++++++++++++++ src/lib/result.ts | 10 ++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/lib/result.test.ts b/src/lib/result.test.ts index cd99b20b..5c9615e2 100644 --- a/src/lib/result.test.ts +++ b/src/lib/result.test.ts @@ -11,11 +11,26 @@ describe('Result', () => { it('err carries code, message, retryable', () => { const r = err('rate_limited', 'too many requests', true); + expect(isErr(r)).toBe(true); + if (isErr(r)) { expect(r.error.code).toBe('rate_limited'); expect(r.error.message).toBe('too many requests'); expect(r.error.retryable).toBe(true); + expect(r.error.resetAt).toBeUndefined(); + } + }); + + it('err carries resetAt when provided', () => { + const resetAt = Date.now() + 60000; + + const r = err('rate_limited', 'too many requests', true, resetAt); + + expect(isErr(r)).toBe(true); + + if (isErr(r)) { + expect(r.error.resetAt).toBe(resetAt); } }); diff --git a/src/lib/result.ts b/src/lib/result.ts index 78afa3f8..9c6f2e7a 100644 --- a/src/lib/result.ts +++ b/src/lib/result.ts @@ -9,14 +9,20 @@ export type AppError = { code: string; message: string; retryable: boolean; + resetAt?: number; }; export function ok(data: T): Result { return { ok: true, data }; } -export function err(code: string, message: string, retryable = false): Result { - return { ok: false, error: { code, message, retryable } }; +export function err( + code: string, + message: string, + retryable = false, + resetAt?: number, +): Result { + return { ok: false, error: { code, message, retryable, resetAt } }; } export function isOk(r: Result): r is { ok: true; data: T } { From a18d88cc11ea1e5573225b9ed73676b127044015 Mon Sep 17 00:00:00 2001 From: Vardhan Date: Sat, 6 Jun 2026 12:23:48 +0530 Subject: [PATCH 2/5] feat(rate-limit): pass resetAt through actions --- src/app/actions/help.ts | 3 ++- src/app/actions/recommendations.test.ts | 6 ++++-- src/app/actions/recommendations.ts | 6 +++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/actions/help.ts b/src/app/actions/help.ts index a968810e..b95c76a7 100644 --- a/src/app/actions/help.ts +++ b/src/app/actions/help.ts @@ -37,7 +37,8 @@ export async function sendHelpRequest(input: HelpInput): Promise { }); it('returns rate_limited error if limit exceeded', async () => { - mocks.mockRateLimit.mockResolvedValueOnce({ ok: false }); + mocks.mockRateLimit.mockResolvedValueOnce({ ok: false, remaining: 0, resetAt: 1234567890 }); const result = await getRecommendations(); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.code).toBe('rate_limited'); + if (!result.ok) { + expect(result.error.code).toBe('rate_limited'); + } }); }); diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index a897edf7..363981ee 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -110,7 +110,7 @@ export async function claimRecommendation(recId: number): Promise Date: Sat, 6 Jun 2026 15:29:12 +0530 Subject: [PATCH 3/5] feat(ui): add reusable cooldown timer component --- src/components/cooldown-timer.tsx | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/components/cooldown-timer.tsx diff --git a/src/components/cooldown-timer.tsx b/src/components/cooldown-timer.tsx new file mode 100644 index 00000000..ee530c01 --- /dev/null +++ b/src/components/cooldown-timer.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type CooldownTimerProps = { + resetAt: number; + onExpire?: () => void; +}; + +export function CooldownTimer({ resetAt, onExpire }: CooldownTimerProps) { + const [now, setNow] = useState(Date.now()); + + const remainingMs = Math.max(0, resetAt - now); + + useEffect(() => { + if (remainingMs <= 0) return; + + const interval = setInterval(() => { + setNow(Date.now()); + }, 1000); + + return () => clearInterval(interval); + }, [remainingMs]); + + useEffect(() => { + if (remainingMs <= 0) { + onExpire?.(); + } + }, [remainingMs, onExpire]); + + const totalSeconds = Math.ceil(remainingMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return ( + + LIMIT REACHED — TRY AGAIN IN {String(minutes).padStart(2, '0')}: + {String(seconds).padStart(2, '0')} + + ); +} From 0204f66e9ebce442284101f178f00ac4df588d76 Mon Sep 17 00:00:00 2001 From: Vardhan Date: Sat, 6 Jun 2026 16:20:58 +0530 Subject: [PATCH 4/5] feat(ui): show rate limit countdown timer --- src/app/(app)/dashboard/rec-cards.tsx | 63 ++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/src/app/(app)/dashboard/rec-cards.tsx b/src/app/(app)/dashboard/rec-cards.tsx index 7de8b626..56cb13c0 100644 --- a/src/app/(app)/dashboard/rec-cards.tsx +++ b/src/app/(app)/dashboard/rec-cards.tsx @@ -9,6 +9,7 @@ import { type RecCard, } from '@/app/actions/recommendations'; import { sendHelpRequest } from '@/app/actions/help'; +import { CooldownTimer } from '@/components/cooldown-timer'; const PR_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/; @@ -24,6 +25,13 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) { const [pending, startTransition] = useTransition(); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); + const [cooldownUntil, setCooldownUntil] = useState(null); + + function handleRateLimit(resetAt?: number) { + if (resetAt) { + setCooldownUntil(resetAt); + } + } function handleClaim(rec: RecCard) { setBusyId(rec.id); @@ -33,6 +41,10 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) { if (res.ok) { setRecs((prev) => prev.map((r) => (r.id === rec.id ? { ...r, status: 'claimed' } : r))); } else { + if (res.error.code === 'rate_limited') { + handleRateLimit(res.error.resetAt); + } + setError(`${rec.title}: ${res.error.message}`); } setBusyId(null); @@ -50,6 +62,10 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) { return res.data.replacement ? [...without, res.data.replacement] : without; }); } else { + if (res.error.code === 'rate_limited') { + handleRateLimit(res.error.resetAt); + } + setError(`${rec.title}: ${res.error.message}`); } setBusyId(null); @@ -66,13 +82,28 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) { return (
- {error && ( + {cooldownUntil ? (
- {error} + { + setCooldownUntil(null); + setError(null); + }} + />
+ ) : ( + error && ( +
+ {error} +
+ ) )}
    @@ -107,7 +138,7 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) {
    {rec.status === 'claimed' ? ( - + ) : (