Skip to content

Commit 4e25ab2

Browse files
plastuchinoclaude
andcommitted
admin review: show + edit submission hours before approving
The review queue had no way to see or change a record's tracked hours, so an over-counted Hackatime figure couldn't be deflated before approval. - page.tsx: fetch Optional - Override Hours Spent + Hackatime project name into each queue row - AdminQueue: per-row Hours number input with the stored value shown for reference and a "Save hours" action - /api/admin/review: new "hours" action that rewrites Override Hours Spent (rounded to 0.1) without touching Approved / Review Status Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3z1tR9YabNCw8146cPzT7
1 parent 7fa16bd commit 4e25ab2

3 files changed

Lines changed: 65 additions & 5 deletions

File tree

app/admin/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const TELESCREEN_BASE = "https://joe-cool.jollyy.dev/billy/overview";
1212
// leak into this page's payload even by accident.
1313
const QUEUE_FIELDS = [
1414
SUBMISSION_FIELDS.hackatimeId,
15+
SUBMISSION_FIELDS.hackatimeProjects,
16+
SUBMISSION_FIELDS.overrideHours,
1517
SUBMISSION_FIELDS.codeUrl,
1618
SUBMISSION_FIELDS.playableUrl,
1719
SUBMISSION_FIELDS.lapseLinks,
@@ -51,12 +53,15 @@ export default async function AdminPage({
5153
const screenshot = record.fields[SUBMISSION_FIELDS.screenshot] as
5254
| Array<{ url: string }>
5355
| undefined;
56+
const hoursRaw = record.fields[SUBMISSION_FIELDS.overrideHours];
5457
return {
5558
id: record.id,
5659
telescreenLink: `${TELESCREEN_BASE}?u=${encodeURIComponent(hackatimeId)}`,
5760
codeUrl: String(record.fields[SUBMISSION_FIELDS.codeUrl] ?? ""),
5861
playableUrl: String(record.fields[SUBMISSION_FIELDS.playableUrl] ?? ""),
5962
lapseLinks: String(record.fields[SUBMISSION_FIELDS.lapseLinks] ?? ""),
63+
hackatimeProjects: String(record.fields[SUBMISSION_FIELDS.hackatimeProjects] ?? ""),
64+
hours: typeof hoursRaw === "number" ? hoursRaw : 0,
6065
screenshotUrl: screenshot?.[0]?.url ?? null,
6166
approved: Boolean(record.fields[SUBMISSION_FIELDS.approved]),
6267
reviewStatus: String(record.fields[SUBMISSION_FIELDS.reviewStatus] ?? "Pending"),

app/api/admin/review/route.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "../../../../src/lib/airtable";
1111
import { getIdentity } from "../../../../src/lib/hackclub";
1212

13-
const ACTIONS = ["approve", "reject", "fraud"] as const;
13+
const ACTIONS = ["approve", "reject", "fraud", "hours"] as const;
1414
type Action = (typeof ACTIONS)[number];
1515

1616
export async function POST(request: Request) {
@@ -36,6 +36,23 @@ export async function POST(request: Request) {
3636
}
3737

3838
const reviewedAt = new Date().toISOString();
39+
40+
// "hours" is an adjustment action, not a verdict — it only rewrites the
41+
// record's hours (letting a reviewer deflate an over-counted Hackatime
42+
// figure before approving) and leaves Approved / Review Status untouched.
43+
if (action === "hours") {
44+
const hours = Number(body.hours);
45+
if (!Number.isFinite(hours) || hours < 0) {
46+
return NextResponse.json({ error: "invalid_hours" }, { status: 400 });
47+
}
48+
await updateAirtableRecord(recordId, {
49+
[SUBMISSION_FIELDS.overrideHours]: Math.round(hours * 10) / 10,
50+
[SUBMISSION_FIELDS.reviewedAt]: reviewedAt,
51+
[SUBMISSION_FIELDS.reviewedBy]: identity.primary_email,
52+
});
53+
return NextResponse.json({ ok: true });
54+
}
55+
3956
const reviewFields: Record<string, unknown> = {
4057
[SUBMISSION_FIELDS.reviewedAt]: reviewedAt,
4158
[SUBMISSION_FIELDS.reviewedBy]: identity.primary_email,

app/components/admin/AdminQueue.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type AdminSubmissionRow = {
99
codeUrl: string;
1010
playableUrl: string;
1111
lapseLinks: string;
12+
hackatimeProjects: string;
13+
hours: number;
1214
screenshotUrl: string | null;
1315
approved: boolean;
1416
reviewStatus: string;
@@ -26,9 +28,14 @@ export default function AdminQueue({
2628
filter: Filter;
2729
}) {
2830
const [rejectDraft, setRejectDraft] = useState<Record<string, string>>({});
31+
const [hoursDraft, setHoursDraft] = useState<Record<string, string>>({});
2932
const [busy, setBusy] = useState<string | null>(null);
3033

31-
async function act(recordId: string, action: "approve" | "reject" | "fraud") {
34+
async function act(
35+
recordId: string,
36+
action: "approve" | "reject" | "fraud" | "hours",
37+
extra?: Record<string, unknown>,
38+
) {
3239
const message = action === "reject" ? rejectDraft[recordId]?.trim() : undefined;
3340
if (action === "reject" && !message) return;
3441

@@ -37,7 +44,7 @@ export default function AdminQueue({
3744
const res = await fetch("/api/admin/review", {
3845
method: "POST",
3946
headers: { "Content-Type": "application/json" },
40-
body: JSON.stringify({ recordId, action, message }),
47+
body: JSON.stringify({ recordId, action, message, ...extra }),
4148
});
4249
if (res.ok) {
4350
window.location.reload();
@@ -59,7 +66,14 @@ export default function AdminQueue({
5966

6067
{rows.length === 0 && <p className="opacity-60">No submissions in this view.</p>}
6168

62-
{rows.map((row) => (
69+
{rows.map((row) => {
70+
const hoursValue = hoursDraft[row.id] ?? String(row.hours);
71+
const parsedHours = Number(hoursValue);
72+
const hoursValid = Number.isFinite(parsedHours) && parsedHours >= 0;
73+
const hoursChanged =
74+
hoursValid && Math.round(parsedHours * 10) / 10 !== Math.round(row.hours * 10) / 10;
75+
76+
return (
6377
<div key={row.id} className="card bg-base-200 p-4 gap-3">
6478
<div className="flex gap-4 items-start flex-wrap">
6579
{row.screenshotUrl && (
@@ -77,12 +91,35 @@ export default function AdminQueue({
7791
Playable URL
7892
</a>
7993
{row.lapseLinks && <p>Lapse: {row.lapseLinks}</p>}
94+
{row.hackatimeProjects && <p>Project: {row.hackatimeProjects}</p>}
8095
<p className="opacity-60">
8196
{row.approved ? "Approved" : row.reviewStatus}
8297
</p>
8398
</div>
8499
</div>
85100

101+
<div className="flex gap-2 flex-wrap items-center">
102+
<label className="text-sm opacity-70">Hours</label>
103+
<input
104+
type="number"
105+
step="0.1"
106+
min="0"
107+
className="input input-bordered input-sm w-24"
108+
value={hoursValue}
109+
onChange={(e) => setHoursDraft((d) => ({ ...d, [row.id]: e.target.value }))}
110+
/>
111+
<span className="text-xs opacity-60">
112+
stored: {Math.round(row.hours * 10) / 10}h
113+
</span>
114+
<button
115+
className="btn btn-sm"
116+
disabled={busy === row.id || !hoursValid || !hoursChanged}
117+
onClick={() => act(row.id, "hours", { hours: parsedHours })}
118+
>
119+
Save hours
120+
</button>
121+
</div>
122+
86123
<div className="flex gap-2 flex-wrap items-center">
87124
<button
88125
className="btn btn-success btn-sm"
@@ -120,7 +157,8 @@ export default function AdminQueue({
120157
</div>
121158
</details>
122159
</div>
123-
))}
160+
);
161+
})}
124162
</div>
125163
);
126164
}

0 commit comments

Comments
 (0)