Skip to content

Commit 65ee018

Browse files
prazgaitisclaude
andauthored
Achievement admin editing, activity type kinds, and n_of_thresholds criteria (#242)
- Add inline editing for achievements in admin panel (extract shared form component, wire up updateAchievement mutation, pre-populate form from existing data) - Add `kind` field to activityTypes and templateActivityTypes schema (core/challenge/bonus/penalty/tracking) with backfillKind mutation - Add kind selector to admin activity types table (create, edit, display column) - Add `n_of_thresholds` achievement criteria type for "any N of these per-type thresholds" (e.g. March Fitness Triathlon: complete any 3 marathon-length activities) - Add tests for kind backfill heuristics (14 tests) and n_of_thresholds (5 tests) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c796f24 commit 65ee018

11 files changed

Lines changed: 1414 additions & 430 deletions

File tree

apps/web/app/challenges/[id]/admin/achievements/page.tsx

Lines changed: 633 additions & 420 deletions
Large diffs are not rendered by default.

apps/web/components/admin/admin-activity-types-table.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export function AdminActivityTypesTable({
106106
const [createNegative, setCreateNegative] = useState(false);
107107
const [createAvailableInFinalDays, setCreateAvailableInFinalDays] = useState(false);
108108
const [createCategoryId, setCreateCategoryId] = useState("");
109+
const [createKind, setCreateKind] = useState("");
109110
const [createDisplayOrder, setCreateDisplayOrder] = useState("");
110111

111112
const [editingId, setEditingId] = useState<string | null>(null);
@@ -116,6 +117,7 @@ export function AdminActivityTypesTable({
116117
const [editNegative, setEditNegative] = useState(false);
117118
const [editAvailableInFinalDays, setEditAvailableInFinalDays] = useState(false);
118119
const [editCategoryId, setEditCategoryId] = useState("");
120+
const [editKind, setEditKind] = useState("");
119121
const [editDisplayOrder, setEditDisplayOrder] = useState("");
120122
const [editThresholds, setEditThresholds] = useState<BonusThreshold[]>([]);
121123
const [editScoringConfig, setEditScoringConfig] = useState<Record<string, unknown> | null>(null);
@@ -157,6 +159,7 @@ export function AdminActivityTypesTable({
157159
isNegative: createNegative,
158160
availableInFinalDays: createAvailableInFinalDays || undefined,
159161
categoryId: createCategoryId ? (createCategoryId as Id<"categories">) : undefined,
162+
kind: (createKind || undefined) as "core" | "challenge" | "bonus" | "penalty" | "tracking" | undefined,
160163
displayOrder: Number.isFinite(parsedDisplayOrder) && createDisplayOrder !== "" ? parsedDisplayOrder : undefined,
161164
});
162165
setCreateName("");
@@ -166,6 +169,7 @@ export function AdminActivityTypesTable({
166169
setCreateNegative(false);
167170
setCreateAvailableInFinalDays(false);
168171
setCreateCategoryId("");
172+
setCreateKind("");
169173
setCreateDisplayOrder("");
170174
setShowCreate(false);
171175
setStatusMessage({ type: "success", text: "Created" });
@@ -188,6 +192,7 @@ export function AdminActivityTypesTable({
188192
setEditNegative(item.isNegative);
189193
setEditAvailableInFinalDays(item.availableInFinalDays ?? false);
190194
setEditCategoryId((item.categoryId as string) ?? "");
195+
setEditKind((item as any).kind ?? "");
191196
setEditDisplayOrder(item.displayOrder != null ? String(item.displayOrder) : "");
192197
setEditThresholds((item.bonusThresholds as BonusThreshold[]) || []);
193198
setEditScoringConfig(scoringConfig);
@@ -251,6 +256,7 @@ export function AdminActivityTypesTable({
251256
availableInFinalDays: editAvailableInFinalDays || undefined,
252257
bonusThresholds: editThresholds,
253258
categoryId: editCategoryId ? (editCategoryId as Id<"categories">) : undefined,
259+
kind: (editKind || undefined) as "core" | "challenge" | "bonus" | "penalty" | "tracking" | undefined,
254260
displayOrder: Number.isFinite(parsedDisplayOrder) && editDisplayOrder !== "" ? parsedDisplayOrder : undefined,
255261
});
256262
setEditingId(null);
@@ -407,6 +413,21 @@ export function AdminActivityTypesTable({
407413
))}
408414
</select>
409415
</div>
416+
<div className="w-28">
417+
<label className="mb-1 block text-[10px] text-zinc-500">Kind</label>
418+
<select
419+
value={createKind}
420+
onChange={(e) => setCreateKind(e.target.value)}
421+
className="h-8 w-full rounded border border-zinc-700 bg-zinc-800 px-2 text-sm text-zinc-300"
422+
>
423+
<option value=""></option>
424+
<option value="core">Core</option>
425+
<option value="challenge">Challenge</option>
426+
<option value="bonus">Bonus</option>
427+
<option value="penalty">Penalty</option>
428+
<option value="tracking">Tracking</option>
429+
</select>
430+
</div>
410431
<div className="w-20">
411432
<label className="mb-1 block text-[10px] text-zinc-500" title="Display order in logging menu (lower = first)">Order #</label>
412433
<Input
@@ -454,6 +475,7 @@ export function AdminActivityTypesTable({
454475
<tr className="border-b border-zinc-800 text-[10px] uppercase tracking-wider text-zinc-500">
455476
<th className="px-3 py-2 text-left font-medium">Name</th>
456477
<th className="px-3 py-2 text-left font-medium">Category</th>
478+
<th className="px-3 py-2 text-left font-medium">Kind</th>
457479
<th className="px-3 py-2 text-right font-medium">Points</th>
458480
<th className="px-3 py-2 text-center font-medium">Streak</th>
459481
<th className="px-3 py-2 text-center font-medium">Neg</th>
@@ -465,7 +487,7 @@ export function AdminActivityTypesTable({
465487
<tbody className="divide-y divide-zinc-800/50">
466488
{items.length === 0 ? (
467489
<tr>
468-
<td colSpan={8} className="px-3 py-8 text-center text-zinc-500">
490+
<td colSpan={9} className="px-3 py-8 text-center text-zinc-500">
469491
No activity types configured
470492
</td>
471493
</tr>
@@ -496,6 +518,15 @@ export function AdminActivityTypesTable({
496518
<td className="px-3 py-2 text-zinc-400">
497519
{item.categoryId ? categoryMap.get(item.categoryId as string) ?? "—" : "—"}
498520
</td>
521+
<td className="px-3 py-2">
522+
{(item as any).kind ? (
523+
<span className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-400">
524+
{(item as any).kind}
525+
</span>
526+
) : (
527+
<span className="text-zinc-600"></span>
528+
)}
529+
</td>
499530
<td className="px-3 py-2 text-right font-mono text-zinc-300">
500531
{basePoints}
501532
</td>
@@ -545,7 +576,7 @@ export function AdminActivityTypesTable({
545576
{/* Edit Panel */}
546577
{isEditing && (
547578
<tr>
548-
<td colSpan={8} className="border-t border-zinc-800 bg-zinc-900 p-0">
579+
<td colSpan={9} className="border-t border-zinc-800 bg-zinc-900 p-0">
549580
<form onSubmit={handleUpdate} className="p-3">
550581
{/* Basic Fields Row */}
551582
<div className="flex items-end gap-3">
@@ -613,6 +644,23 @@ export function AdminActivityTypesTable({
613644
))}
614645
</select>
615646
</div>
647+
<div className="w-28">
648+
<label className="mb-1 block text-[10px] text-zinc-500">
649+
Kind
650+
</label>
651+
<select
652+
value={editKind}
653+
onChange={(e) => setEditKind(e.target.value)}
654+
className="h-8 w-full rounded border border-zinc-700 bg-zinc-800 px-2 text-sm text-zinc-300"
655+
>
656+
<option value=""></option>
657+
<option value="core">Core</option>
658+
<option value="challenge">Challenge</option>
659+
<option value="bonus">Bonus</option>
660+
<option value="penalty">Penalty</option>
661+
<option value="tracking">Tracking</option>
662+
</select>
663+
</div>
616664
<div className="w-20">
617665
<label className="mb-1 block text-[10px] text-zinc-500" title="Controls order in the logging menu (lower = first)">
618666
Order #

apps/web/tests/api/achievements.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,3 +952,151 @@ describe("HTTP API: achievement progress endpoint shape", () => {
952952
expect(byName["OneOfEach"].requiredCount).toBe(2);
953953
});
954954
});
955+
956+
// ─── N_OF_THRESHOLDS criteria ────────────────────────────────────────────────
957+
958+
describe("Criteria: n_of_thresholds", () => {
959+
let t: ReturnType<typeof createTestContext>;
960+
let userId: Id<"users">;
961+
let challengeId: Id<"challenges">;
962+
let runTypeId: Id<"activityTypes">;
963+
let cycleTypeId: Id<"activityTypes">;
964+
let swimTypeId: Id<"activityTypes">;
965+
let rowTypeId: Id<"activityTypes">;
966+
let tWithAuth: any;
967+
const EMAIL = "triathlete@example.com";
968+
969+
beforeEach(async () => {
970+
t = createTestContext();
971+
userId = await createTestUser(t, { email: EMAIL });
972+
tWithAuth = t.withIdentity({ subject: "sub-triathlete", email: EMAIL });
973+
challengeId = await createTestChallenge(t, userId);
974+
runTypeId = await createTestActivityType(t, challengeId, {
975+
name: "Outdoor Run",
976+
scoringConfig: { type: "unit_based", pointsPerUnit: 8, unit: "miles" },
977+
});
978+
cycleTypeId = await createTestActivityType(t, challengeId, {
979+
name: "Outdoor Cycling",
980+
scoringConfig: { type: "unit_based", pointsPerUnit: 2.6, unit: "miles" },
981+
});
982+
swimTypeId = await createTestActivityType(t, challengeId, {
983+
name: "Swimming",
984+
scoringConfig: { type: "unit_based", pointsPerUnit: 33, unit: "miles" },
985+
});
986+
rowTypeId = await createTestActivityType(t, challengeId, {
987+
name: "Rowing",
988+
scoringConfig: { type: "unit_based", pointsPerUnit: 3.75, unit: "kilometers" },
989+
});
990+
await createTestParticipation(t, userId, challengeId);
991+
});
992+
993+
it("awards when N of the thresholds are met (3 of 4)", async () => {
994+
await createTestAchievement(t, challengeId, {
995+
criteriaType: "n_of_thresholds",
996+
requiredCount: 3,
997+
requirements: [
998+
{ activityTypeId: runTypeId, metric: "distance_miles", threshold: 26.2 },
999+
{ activityTypeId: cycleTypeId, metric: "distance_miles", threshold: 112 },
1000+
{ activityTypeId: swimTypeId, metric: "distance_miles", threshold: 2.4 },
1001+
{ activityTypeId: rowTypeId, metric: "distance_km", threshold: 42.2 },
1002+
],
1003+
});
1004+
1005+
// Meet 2 of 4 — not enough
1006+
await logActivity(tWithAuth, challengeId, runTypeId, { miles: 26.5 });
1007+
await logActivity(tWithAuth, challengeId, swimTypeId, { miles: 3 });
1008+
let earned = await getEarnedAchievements(t, userId, challengeId);
1009+
expect(earned).toHaveLength(0);
1010+
1011+
// Meet 3rd threshold — should award
1012+
await logActivity(tWithAuth, challengeId, cycleTypeId, { miles: 115 });
1013+
earned = await getEarnedAchievements(t, userId, challengeId);
1014+
expect(earned).toHaveLength(1);
1015+
});
1016+
1017+
it("does not award when below threshold even if enough types logged", async () => {
1018+
await createTestAchievement(t, challengeId, {
1019+
criteriaType: "n_of_thresholds",
1020+
requiredCount: 2,
1021+
requirements: [
1022+
{ activityTypeId: runTypeId, metric: "distance_miles", threshold: 26.2 },
1023+
{ activityTypeId: cycleTypeId, metric: "distance_miles", threshold: 112 },
1024+
{ activityTypeId: swimTypeId, metric: "distance_miles", threshold: 2.4 },
1025+
],
1026+
});
1027+
1028+
// Log all 3 types but only 1 meets its threshold
1029+
await logActivity(tWithAuth, challengeId, runTypeId, { miles: 26.5 }); // meets 26.2
1030+
await logActivity(tWithAuth, challengeId, cycleTypeId, { miles: 50 }); // below 112
1031+
await logActivity(tWithAuth, challengeId, swimTypeId, { miles: 1 }); // below 2.4
1032+
1033+
const earned = await getEarnedAchievements(t, userId, challengeId);
1034+
expect(earned).toHaveLength(0);
1035+
});
1036+
1037+
it("awards when exactly requiredCount are met", async () => {
1038+
await createTestAchievement(t, challengeId, {
1039+
criteriaType: "n_of_thresholds",
1040+
requiredCount: 2,
1041+
requirements: [
1042+
{ activityTypeId: runTypeId, metric: "distance_miles", threshold: 26.2 },
1043+
{ activityTypeId: cycleTypeId, metric: "distance_miles", threshold: 112 },
1044+
{ activityTypeId: swimTypeId, metric: "distance_miles", threshold: 2.4 },
1045+
],
1046+
});
1047+
1048+
await logActivity(tWithAuth, challengeId, runTypeId, { miles: 30 });
1049+
await logActivity(tWithAuth, challengeId, swimTypeId, { miles: 3 });
1050+
1051+
const earned = await getEarnedAchievements(t, userId, challengeId);
1052+
expect(earned).toHaveLength(1);
1053+
});
1054+
1055+
it("getUserProgress reports correct counts", async () => {
1056+
const achievementId = await createTestAchievement(t, challengeId, {
1057+
criteriaType: "n_of_thresholds",
1058+
requiredCount: 3,
1059+
requirements: [
1060+
{ activityTypeId: runTypeId, metric: "distance_miles", threshold: 26.2 },
1061+
{ activityTypeId: cycleTypeId, metric: "distance_miles", threshold: 112 },
1062+
{ activityTypeId: swimTypeId, metric: "distance_miles", threshold: 2.4 },
1063+
{ activityTypeId: rowTypeId, metric: "distance_km", threshold: 42.2 },
1064+
],
1065+
});
1066+
1067+
// Meet 1 of 4
1068+
await logActivity(tWithAuth, challengeId, runTypeId, { miles: 27 });
1069+
1070+
const progress = await tWithAuth.query(
1071+
api.queries.achievements.getUserProgress,
1072+
{ challengeId },
1073+
);
1074+
1075+
const entry = progress.find((p: any) => p.achievementId === achievementId);
1076+
expect(entry).toBeDefined();
1077+
expect(entry.currentCount).toBe(1);
1078+
expect(entry.requiredCount).toBe(3); // not 4 (total requirements)
1079+
expect(entry.isEarned).toBe(false);
1080+
});
1081+
1082+
it("respects once_per_challenge — no duplicate awards", async () => {
1083+
await createTestAchievement(t, challengeId, {
1084+
criteriaType: "n_of_thresholds",
1085+
requiredCount: 2,
1086+
requirements: [
1087+
{ activityTypeId: runTypeId, metric: "distance_miles", threshold: 26.2 },
1088+
{ activityTypeId: cycleTypeId, metric: "distance_miles", threshold: 112 },
1089+
{ activityTypeId: swimTypeId, metric: "distance_miles", threshold: 2.4 },
1090+
],
1091+
});
1092+
1093+
// Meet all 3 in separate logs
1094+
await logActivity(tWithAuth, challengeId, runTypeId, { miles: 30 });
1095+
await logActivity(tWithAuth, challengeId, swimTypeId, { miles: 3 });
1096+
await logActivity(tWithAuth, challengeId, cycleTypeId, { miles: 120 });
1097+
1098+
const earned = await getEarnedAchievements(t, userId, challengeId);
1099+
// Should still only have 1 award (once_per_challenge)
1100+
expect(earned).toHaveLength(1);
1101+
});
1102+
});

0 commit comments

Comments
 (0)