Skip to content
Open
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@octokit/auth-app": "^7.1.1",
"@octokit/rest": "^21.0.2",
"@octokit/webhooks": "^13.4.1",
"@supabase/ssr": "^0.5.2",
"@supabase/ssr": "^0.5.0",
"@supabase/supabase-js": "^2.46.0",
"@types/three": "^0.184.1",
"@upstash/redis": "^1.34.3",
Expand All @@ -37,7 +37,7 @@
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"drizzle-orm": "^0.36.4",
"framer-motion": "^11.0.0",
"framer-motion": "^12.0.0",
"groq-sdk": "^0.9.1",
"inngest": "^3.27.0",
"lucide-react": "^0.400.0",
Expand Down
30 changes: 21 additions & 9 deletions src/app/(app)/dashboard/level-up-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,46 @@ import {
acknowledgeLevelUp,
type LevelUpRow,
} from '@/app/actions/level-ups';
import { useToast } from '@/components/toast';


/**
* Reads any unacknowledged level-up rows on mount and shows a dismissable
* banner. Acknowledges optimistically so a refresh-spam can't re-trigger
* the same celebration.
*/
export default function LevelUpBanner() {
const [rows, setRows] = useState<LevelUpRow[]>([]);
const [, startTransition] = useTransition();
const { addToast } = useToast();

useEffect(() => {
let live = true;
(async () => {
const res = await getUnacknowledgedLevelUps();
if (live && res.ok) setRows(res.data);
if (!live || !res.ok) return;

const levelUps = res.data;
setRows(levelUps);


levelUps.forEach((row, i) => {
setTimeout(() => {
addToast(`⬆ LEVEL UP — YOU ARE NOW L${row.toLevel}`, 'level-up');
}, i * 400);
});
})();
return () => {
live = false;
};

}, []);


if (rows.length === 0) return null;
const top = rows[0]!;

function dismiss(id: number) {
setRows((prev) => prev.filter((r) => r.id !== id));
startTransition(async () => {
await acknowledgeLevelUp(id);
startTransition(() => {
void (async () => {
await acknowledgeLevelUp(id);
})();
});
}

Expand All @@ -58,4 +70,4 @@ export default function LevelUpBanner() {
</button>
</div>
);
}
}
65 changes: 38 additions & 27 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 { useToast } from '@/components/toast';

const PR_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/;

Expand All @@ -24,35 +25,41 @@ 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 { addToast } = useToast();

function handleClaim(rec: RecCard) {
setBusyId(rec.id);
setError(null);
startTransition(async () => {
const res = await claimRecommendation(rec.id);
if (res.ok) {
setRecs((prev) => prev.map((r) => (r.id === rec.id ? { ...r, status: 'claimed' } : r)));
} else {
setError(`${rec.title}: ${res.error.message}`);
}
setBusyId(null);
startTransition(() => {
void (async () => {
const res = await claimRecommendation(rec.id);
if (res.ok) {
setRecs((prev) => prev.map((r) => (r.id === rec.id ? { ...r, status: 'claimed' } : r)));
addToast(`+${rec.xpReward} XP — ISSUE CLAIMED`, 'success');
} else {
setError(`${rec.title}: ${res.error.message}`);
}
setBusyId(null);
})();
});
}

function handleSkip(rec: RecCard) {
setBusyId(rec.id);
setError(null);
startTransition(async () => {
const res = await skipRecommendation(rec.id);
if (res.ok) {
setRecs((prev) => {
const without = prev.filter((r) => r.id !== rec.id);
return res.data.replacement ? [...without, res.data.replacement] : without;
});
} else {
setError(`${rec.title}: ${res.error.message}`);
}
setBusyId(null);
startTransition(() => {
void (async () => {
const res = await skipRecommendation(rec.id);
if (res.ok) {
setRecs((prev) => {
const without = prev.filter((r) => r.id !== rec.id);
return res.data.replacement ? [...without, res.data.replacement] : without;
});
} else {
setError(`${rec.title}: ${res.error.message}`);
}
setBusyId(null);
})();
});
}

Expand Down Expand Up @@ -149,10 +156,12 @@ function ClaimedActions({ rec, onError }: { rec: RecCard; onError: (msg: string
function onLink() {
if (!isValidPrUrl) return;
onError(null);
startTransition(async () => {
const res = await linkPrToRec(rec.id, input.trim());
if (res.ok) setLinked(true);
else onError(`${rec.title}: ${res.error.message}`);
startTransition(() => {
void (async () => {
const res = await linkPrToRec(rec.id, input.trim());
if (res.ok) setLinked(true);
else onError(`${rec.title}: ${res.error.message}`);
})();
});
}

Expand All @@ -162,10 +171,12 @@ function ClaimedActions({ rec, onError }: { rec: RecCard; onError: (msg: string
return;
}
onError(null);
startTransition(async () => {
const res = await sendHelpRequest({ recId: rec.id, prUrl: input.trim() });
if (res.ok) setHelpSent(true);
else onError(`${rec.title}: ${res.error.message}`);
startTransition(() => {
void (async () => {
const res = await sendHelpRequest({ recId: rec.id, prUrl: input.trim() });
if (res.ok) setHelpSent(true);
else onError(`${rec.title}: ${res.error.message}`);
})();
});
}

Expand Down
16 changes: 14 additions & 2 deletions src/app/(app)/dashboard/sync-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { RefreshCw } from 'lucide-react';
import { syncGitHubStats } from '@/app/actions/github-sync';
import { useToast } from '@/components/toast';

type Props = {
lastSyncedAt: string | null;
Expand All @@ -15,6 +16,7 @@ export function SyncButton({ lastSyncedAt }: Props) {
const [cooldown, setCooldown] = useState(false);
const [localSyncedAt, setLocalSyncedAt] = useState(lastSyncedAt);
const router = useRouter();
const { addToast } = useToast();

const handleSync = useCallback(async () => {
if (syncing || cooldown) return;
Expand All @@ -28,11 +30,21 @@ export function SyncButton({ lastSyncedAt }: Props) {
setLocalSyncedAt(new Date().toISOString());
setCooldown(true);
setTimeout(() => setCooldown(false), 60_000);

// Show sync-complete toast
const { merges, streak } = result.data;
addToast(
merges > 0
? `GITHUB SYNCED — ${merges} MERGE${merges !== 1 ? 'S' : ''} · ${streak}D STREAK`
: 'GITHUB SYNCED',
'success',
);

router.refresh();
} else {
setError(result.error.message);
}
}, [syncing, cooldown, router]);
}, [syncing, cooldown, router, addToast]);

return (
<div className="flex flex-col items-end gap-1">
Expand Down Expand Up @@ -65,4 +77,4 @@ function formatSyncedAt(iso: string | null): string {
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `LAST SYNCED ${hrs}H AGO`;
return `LAST SYNCED ${Math.floor(hrs / 24)}D AGO`;
}
}
23 changes: 14 additions & 9 deletions src/app/(app)/issues/issues-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type IssuesPageResult,
type RepoOption,
} from '@/app/actions/issues';
import { useToast } from '@/components/toast';

const DIFFICULTY_LABEL: Record<string, string> = { E: 'L1', M: 'L2', H: 'L3' };
const DIFFICULTY_COLOR: Record<string, string> = {
Expand All @@ -36,7 +37,7 @@ function IssueCard({
actionPending,
}: {
issue: IssueWithStatus;
onClaim: (id: number) => void;
onClaim: (id: number, xpReward: number | null) => void;
onUnclaim: (recId: number) => void;
actionPending: boolean;
}) {
Expand All @@ -48,12 +49,8 @@ function IssueCard({

const handleCopy = async () => {
await navigator.clipboard.writeText(issue.url);

setCopied(true);

setTimeout(() => {
setCopied(false);
}, 1500);
setTimeout(() => setCopied(false), 1500);
};

return (
Expand Down Expand Up @@ -160,7 +157,7 @@ function IssueCard({
) : (
<>
<button
onClick={() => onClaim(issue.id)}
onClick={() => onClaim(issue.id, issue.xpReward)}
disabled={actionPending}
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"
>
Expand Down Expand Up @@ -216,6 +213,7 @@ export function IssuesList({
const [isPending, startTransition] = useTransition();
const [actionIssueId, setActionIssueId] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const { addToast } = useToast();

const [search, setSearch] = useState(initialFilters.search ?? '');
const [state, setState] = useState<'open' | 'closed'>(initialFilters.state ?? 'open');
Expand Down Expand Up @@ -254,15 +252,22 @@ export function IssuesList({
[router, search, state, difficulty, repo, showClaimed],
);

const handleClaim = async (issueId: number) => {
const handleClaim = async (issueId: number, xpReward: number | null) => {
setActionIssueId(issueId);
setActionError(null);

const result = await claimIssue(issueId);
setActionIssueId(null);

if (!result.ok) {
setActionError(result.error.message);
return;
}

// 🎉 XP toast
const xpMsg = xpReward ? `+${xpReward} XP` : '+XP';
addToast(`${xpMsg} — ISSUE CLAIMED`, 'success');

router.refresh();
};

Expand Down Expand Up @@ -410,4 +415,4 @@ export function IssuesList({
)}
</div>
);
}
}
Loading
Loading