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
84 changes: 74 additions & 10 deletions src/app/(app)/dashboard/rec-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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+$/;

Expand All @@ -24,6 +25,13 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) {
const [pending, startTransition] = useTransition();
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [cooldownUntil, setCooldownUntil] = useState<number | null>(null);

function handleRateLimit(resetAt?: number) {
if (resetAt) {
setCooldownUntil(resetAt);
}
}

function handleClaim(rec: RecCard) {
setBusyId(rec.id);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -66,13 +82,28 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) {

return (
<div>
{error && (
{cooldownUntil ? (
<div
className="mb-4 border border-red-800 bg-red-900/20 px-4 py-3 text-[11px] uppercase tracking-widest text-red-400"
role="alert"
>
{error}
<CooldownTimer
resetAt={cooldownUntil}
onExpire={() => {
setCooldownUntil(null);
setError(null);
}}
/>
</div>
) : (
error && (
<div
className="mb-4 border border-red-800 bg-red-900/20 px-4 py-3 text-[11px] uppercase tracking-widest text-red-400"
role="alert"
>
{error}
</div>
)
)}
<div className="max-h-[520px] overflow-y-auto pr-1 [&::-webkit-scrollbar-thumb:hover]:bg-zinc-500 [&::-webkit-scrollbar-thumb]:bg-zinc-700 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-1">
<ul>
Expand Down Expand Up @@ -107,19 +138,30 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) {

<div className="flex items-center justify-between">
{rec.status === 'claimed' ? (
<ClaimedActions rec={rec} onError={setError} />
<ClaimedActions
rec={rec}
onError={setError}
onRateLimit={handleRateLimit}
isCoolingDown={cooldownUntil !== null && cooldownUntil > Date.now()}
/>
) : (
<div className="flex items-center gap-3">
<button
onClick={() => handleClaim(rec)}
disabled={pending && busyId === rec.id}
disabled={
(cooldownUntil !== null && cooldownUntil > Date.now()) ||
(pending && busyId === rec.id)
}
className="border border-zinc-600 px-4 py-1.5 text-[10px] uppercase tracking-widest text-zinc-300 transition-colors hover:border-white hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{busyId === rec.id ? 'CLAIMING...' : 'CLAIM'}
</button>
<button
onClick={() => handleSkip(rec)}
disabled={pending && busyId === rec.id}
disabled={
(cooldownUntil !== null && cooldownUntil > Date.now()) ||
(pending && busyId === rec.id)
}
className="text-[10px] uppercase tracking-widest text-zinc-600 transition-colors hover:text-zinc-400 disabled:opacity-40"
>
SKIP
Expand All @@ -138,7 +180,17 @@ export default function RecCards({ recs: initial }: { recs: RecCard[] }) {
);
}

function ClaimedActions({ rec, onError }: { rec: RecCard; onError: (msg: string | null) => void }) {
function ClaimedActions({
rec,
onError,
onRateLimit,
isCoolingDown,
}: {
rec: RecCard;
onError: (msg: string | null) => void;
onRateLimit: (resetAt?: number) => void;
isCoolingDown: boolean;
}) {
const [input, setInput] = useState('');
const [pending, startTransition] = useTransition();
const [linked, setLinked] = useState(false);
Expand All @@ -152,7 +204,13 @@ function ClaimedActions({ rec, onError }: { rec: RecCard; onError: (msg: string
startTransition(async () => {
const res = await linkPrToRec(rec.id, input.trim());
if (res.ok) setLinked(true);
else onError(`${rec.title}: ${res.error.message}`);
else {
if (res.error.code === 'rate_limited') {
onRateLimit(res.error.resetAt);
}

onError(`${rec.title}: ${res.error.message}`);
}
});
}

Expand All @@ -165,7 +223,13 @@ function ClaimedActions({ rec, onError }: { rec: RecCard; onError: (msg: string
startTransition(async () => {
const res = await sendHelpRequest({ recId: rec.id, prUrl: input.trim() });
if (res.ok) setHelpSent(true);
else onError(`${rec.title}: ${res.error.message}`);
else {
if (res.error.code === 'rate_limited') {
onRateLimit(res.error.resetAt);
}

onError(`${rec.title}: ${res.error.message}`);
}
});
}

Expand Down Expand Up @@ -198,7 +262,7 @@ function ClaimedActions({ rec, onError }: { rec: RecCard; onError: (msg: string
{isValidPrUrl && (
<button
onClick={onLink}
disabled={pending}
disabled={isCoolingDown || pending}
className="border border-zinc-600 px-4 py-1.5 text-[10px] uppercase tracking-widest text-zinc-300 transition-colors hover:border-white hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{pending ? 'LINKING...' : 'LINK PR'}
Expand All @@ -207,7 +271,7 @@ function ClaimedActions({ rec, onError }: { rec: RecCard; onError: (msg: string
{!helpSent && (
<button
onClick={onHelp}
disabled={pending || input.trim().length === 0}
disabled={isCoolingDown || pending || input.trim().length === 0}
className="text-[10px] uppercase tracking-widest text-zinc-600 transition-colors hover:text-zinc-400 disabled:opacity-40"
title="Request review from L2+ contributors"
>
Expand Down
3 changes: 2 additions & 1 deletion src/app/actions/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export async function sendHelpRequest(input: HelpInput): Promise<Result<HelpOutp
limit: 5,
windowSec: 60 * 60,
});
if (!limited.ok) return err('rate_limited', 'too many help requests this hour', true);
if (!limited.ok)
return err('rate_limited', 'too many help requests this hour', true, limited.resetAt);

const trimmed = input.prUrl.trim();
const isGitHubUrl = PR_URL_RE.test(trimmed);
Expand Down
6 changes: 4 additions & 2 deletions src/app/actions/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,12 @@ describe('Recommendations Server Actions', () => {
});

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');
}
});
});

Expand Down
6 changes: 3 additions & 3 deletions src/app/actions/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export async function claimRecommendation(recId: number): Promise<Result<{ id: n
limit: 20,
windowSec: 60,
});
if (!rateRes.ok) return err('rate_limited', 'slow down', true);
if (!rateRes.ok) return err('rate_limited', 'slow down', true, rateRes.resetAt);

// Fast pre-check: reject early if the user is obviously at the limit.
// This is not authoritative — two concurrent requests can both pass it —
Expand Down Expand Up @@ -199,7 +199,7 @@ export async function linkPrToRec(recId: number, prUrl: string): Promise<Result<
limit: 10,
windowSec: 60,
});
if (!rateRes.ok) return err('rate_limited', 'slow down', true);
if (!rateRes.ok) return err('rate_limited', 'slow down', true, rateRes.resetAt);

// Security: verify the authenticated user actually authored this PR.
const sessionRes = await sb.auth.getSession();
Expand Down Expand Up @@ -268,7 +268,7 @@ export async function skipRecommendation(
limit: 30,
windowSec: 60,
});
if (!rateRes.ok) return err('rate_limited', 'slow down', true);
if (!rateRes.ok) return err('rate_limited', 'slow down', true, rateRes.resetAt);

// Atomic skip with the issue id so we know what tier to refill from.
// Persist the optional skip_reason alongside the status change.
Expand Down
41 changes: 41 additions & 0 deletions src/components/cooldown-timer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span>
LIMIT REACHED — TRY AGAIN IN {String(minutes).padStart(2, '0')}:
{String(seconds).padStart(2, '0')}
</span>
);
}
15 changes: 15 additions & 0 deletions src/lib/result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,26 @@ describe('Result<T>', () => {

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);
}
});

Expand Down
10 changes: 8 additions & 2 deletions src/lib/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@ export type AppError = {
code: string;
message: string;
retryable: boolean;
resetAt?: number;
};

export function ok<T>(data: T): Result<T> {
return { ok: true, data };
}

export function err(code: string, message: string, retryable = false): Result<never> {
return { ok: false, error: { code, message, retryable } };
export function err(
code: string,
message: string,
retryable = false,
resetAt?: number,
): Result<never> {
return { ok: false, error: { code, message, retryable, resetAt } };
}

export function isOk<T>(r: Result<T>): r is { ok: true; data: T } {
Expand Down
Loading