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
2 changes: 2 additions & 0 deletions .claude/napkin.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
| 2026-03-10 | self | Passed a dynamic-route path with `[]` and `()` unquoted into `rg`, and zsh globbing failed again while comparing mini-game profile files | Quote every individual dynamic route path in shell commands, even when mixing them with normal paths in the same invocation |
| 2026-03-10 | self | Ran workspace-scoped ESLint from `apps/web` but still passed repo-root file paths, so ESLint reported no matching files | When using `pnpm -F web exec ...` from the workspace directory, pass paths relative to `apps/web` |
| 2026-03-11 | self | Created a task markdown file with shell redirection instead of `apply_patch` | Use `apply_patch` for file creation/editing, including task files required by repo process |
| 2026-03-22 | self | Started fixing PR 239 by introducing a shared lifecycle helper but initially left `activities.ts` half-migrated with stale imports/helpers | After extracting shared codepaths, immediately re-open callsites and trim imports/duplication before running verification |
| 2026-03-23 | self | Assumed backend drift would already be covered by repo lint, but only `apps/web` had ESLint wired | When adding architectural guards for Convex/backend code, first verify the package actually has a `lint` script and ESLint config |
| 2026-03-01 | self | Started repo exploration before reading `.claude/napkin.md` again | Always run `cat .claude/napkin.md` as the very first command in a new session |
| 2026-03-01 | self | CSV row typing was too narrow (`string | number`) and failed once boolean `isNegative` values were added | For CSV builders, include all emitted scalar types up front (`string | number | boolean`) or coerce before push |
Expand Down Expand Up @@ -159,4 +160,5 @@
| 2026-03-02 | self | When adding a new `usePaginatedQuery` for a different Convex query alongside an existing one, both must stay mounted (hooks can't be conditional in React); use `"skip"` arg on the inactive query | Always use `"skip"` sentinel for Convex paginated queries that should be inactive based on tab state |
- `followingOnly` feed filters applied after pagination can return empty initial pages despite available matches later; skip empty pages server-side before returning a page.
- Backend architectural drift is best guarded with narrow ESLint rules on the specific mutation entrypoints rather than broad import bans that interfere with legitimate admin edit/comment flows.
- For admin activity operations, route create/delete through shared `activities` internal mutations so validations, scoring aggregates, streaks, notifications, and tagging stay on the canonical lifecycle path.
| 2026-03-11 | self | Tried to read a Next route file with unquoted brackets and zsh globbed it | Always single-quote `app/**/[param]/**` paths in shell commands |
38 changes: 29 additions & 9 deletions apps/web/app/challenges/[id]/admin/category-leaders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,17 +230,28 @@ export default function CategoryLeadersPage() {
{/* Placements */}
<div className="divide-y divide-zinc-800/50">
{award.placements.map((p: any) => {
const isHonorable = p.placement > 3;
const Icon = PLACEMENT_ICONS[p.placement - 1] ?? Medal;
const color =
PLACEMENT_COLORS[p.placement - 1] ?? "text-zinc-500";
const color = isHonorable
? "text-zinc-600"
: PLACEMENT_COLORS[p.placement - 1] ?? "text-zinc-500";

return (
<div
key={p.user.userId}
className="flex items-center gap-3 px-3 py-2 transition-colors hover:bg-zinc-800/30"
className={cn(
"flex items-center gap-3 px-3 py-2 transition-colors hover:bg-zinc-800/30",
isHonorable && "opacity-50"
)}
>
{/* Placement icon */}
<Icon className={cn("h-4 w-4 flex-shrink-0", color)} />
{/* Placement icon or number */}
{isHonorable ? (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center text-xs font-medium text-zinc-600">
{p.placement}
</span>
) : (
<Icon className={cn("h-4 w-4 flex-shrink-0", color)} />
)}

{/* User */}
<div className="flex min-w-0 flex-1 items-center gap-2">
Expand All @@ -257,21 +268,30 @@ export default function CategoryLeadersPage() {
)?.[0]?.toUpperCase()}
</div>
)}
<span className="truncate text-sm text-zinc-300">
<span className={cn(
"truncate text-sm",
isHonorable ? "text-zinc-500" : "text-zinc-300"
)}>
{p.user.name ?? p.user.username}
</span>
</div>

{/* Category metric value */}
<span className="font-mono text-xs text-zinc-500">
<span className={cn(
"font-mono text-xs",
isHonorable ? "text-zinc-600" : "text-zinc-500"
)}>
{p.totalMetricValue > 0
? `${Math.round(p.totalMetricValue * 100) / 100} ${award.category.unit ?? "pts"}`
: `${p.totalPoints} pts`}
</span>

{/* Bonus */}
<span className="min-w-[4rem] text-right font-mono text-sm font-medium text-emerald-400">
+{p.bonusPoints}
<span className={cn(
"min-w-[4rem] text-right font-mono text-sm font-medium",
isHonorable ? "text-zinc-600" : "text-emerald-400"
)}>
{isHonorable ? "—" : `+${p.bonusPoints}`}
</span>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/challenges/[id]/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default async function ChallengeAdminLayout({
{ href: base, label: "Overview", segment: "(overview)" },
{ href: `${base}/algofeed`, label: "Algo Feed", segment: "algofeed" },
{ href: `${base}/flagged-activities`, label: "Flagged", segment: "flagged-activities" },
{ href: `${base}/log-activity`, label: "Log Activity", segment: "log-activity" },
],
},
{
Expand Down
224 changes: 224 additions & 0 deletions apps/web/app/challenges/[id]/admin/log-activity/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"use client";

import { useState } from "react";
import { useParams } from "next/navigation";
import { useQuery, useMutation } from "@/lib/convex-auth-react";
import { api } from "@repo/backend";
import type { Id } from "@repo/backend/_generated/dataModel";
import { CheckCircle2, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";

export default function AdminLogActivityPage() {
const params = useParams();
const challengeId = params.id as Id<"challenges">;

const [userId, setUserId] = useState("");
const [activityTypeId, setActivityTypeId] = useState("");
const [loggedDate, setLoggedDate] = useState(
new Date().toISOString().split("T")[0]
);
const [pointsOverride, setPointsOverride] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{
success: boolean;
pointsEarned?: number;
error?: string;
} | null>(null);

const participants = useQuery(api.queries.participations.getMentionable, {
challengeId,
});
const activityTypes = useQuery(api.queries.activityTypes.getByChallengeId, {
challengeId,
});
const logActivity = useMutation(api.mutations.admin.adminLogActivityForUser);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!userId || !activityTypeId) return;

setSubmitting(true);
setResult(null);
try {
const res = await logActivity({
challengeId,
userId: userId as Id<"users">,
activityTypeId: activityTypeId as Id<"activityTypes">,
loggedDate,
pointsOverride: pointsOverride ? Number(pointsOverride) : undefined,
notes: notes || undefined,
});
setResult({ success: true, pointsEarned: res.pointsEarned });
// Reset form for next entry
setPointsOverride("");
setNotes("");
} catch (err: any) {
setResult({ success: false, error: err.message ?? "Failed to log activity" });
} finally {
setSubmitting(false);
}
};

const sortedParticipants = participants
? [...participants].sort((a, b) =>
(a.name ?? a.username).localeCompare(b.name ?? b.username)
)
: null;

return (
<div className="mx-auto max-w-lg space-y-4">
<div>
<h1 className="text-sm font-semibold uppercase tracking-wider text-zinc-100">
Log Activity
</h1>
<p className="mt-0.5 text-xs text-zinc-500">
Create an activity on behalf of a participant
</p>
</div>

<form onSubmit={handleSubmit} className="space-y-4">
{/* User selector */}
<div className="space-y-1.5">
<Label htmlFor="user" className="text-xs text-zinc-400">
Participant
</Label>
{!sortedParticipants ? (
<div className="flex h-9 items-center">
<Loader2 className="h-4 w-4 animate-spin text-zinc-500" />
</div>
) : (
<Select value={userId} onValueChange={setUserId}>
<SelectTrigger id="user" className="h-9 text-sm">
<SelectValue placeholder="Select participant..." />
</SelectTrigger>
<SelectContent>
{sortedParticipants.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name ?? p.username}{" "}
<span className="text-muted-foreground">@{p.username}</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>

{/* Activity type */}
<div className="space-y-1.5">
<Label htmlFor="activity-type" className="text-xs text-zinc-400">
Activity Type
</Label>
{!activityTypes ? (
<div className="flex h-9 items-center">
<Loader2 className="h-4 w-4 animate-spin text-zinc-500" />
</div>
) : (
<Select value={activityTypeId} onValueChange={setActivityTypeId}>
<SelectTrigger id="activity-type" className="h-9 text-sm">
<SelectValue placeholder="Select activity type..." />
</SelectTrigger>
<SelectContent>
{activityTypes.map((at: { _id: string; name: string }) => (
<SelectItem key={at._id} value={at._id}>
{at.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>

{/* Date */}
<div className="space-y-1.5">
<Label htmlFor="logged-date" className="text-xs text-zinc-400">
Date
</Label>
<Input
id="logged-date"
type="date"
value={loggedDate}
onChange={(e) => setLoggedDate(e.target.value)}
className="h-9 text-sm"
/>
</div>

{/* Points override */}
<div className="space-y-1.5">
<Label htmlFor="points-override" className="text-xs text-zinc-400">
Points Override{" "}
<span className="text-zinc-600">(leave blank for auto-calc)</span>
</Label>
<Input
id="points-override"
inputMode="decimal"
value={pointsOverride}
onChange={(e) => setPointsOverride(e.target.value)}
placeholder="Auto-calculated"
className="h-9 text-sm"
/>
</div>

{/* Notes */}
<div className="space-y-1.5">
<Label htmlFor="notes" className="text-xs text-zinc-400">
Notes
</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="text-sm"
placeholder="Optional notes..."
/>
</div>

{/* Result */}
{result && (
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm",
result.success
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
)}
>
{result.success ? (
<>
<CheckCircle2 className="h-4 w-4" />
<span>
Activity logged — {result.pointsEarned} points earned
</span>
</>
) : (
<span>{result.error}</span>
)}
</div>
)}

{/* Submit */}
<Button
type="submit"
disabled={submitting || !userId || !activityTypeId}
className="w-full gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
Log Activity
</Button>
</form>
</div>
);
}
Loading
Loading