Skip to content

Commit b07e805

Browse files
committed
Add soft delete activities and owner delete UI
1 parent c4493ac commit b07e805

21 files changed

Lines changed: 340 additions & 56 deletions

.claude/napkin.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@
1818
- `page-with-header` CSS class = `pt-16` to offset fixed navbar
1919
- Seed data lives in `packages/backend/actions/seed.ts`
2020
- Schema changes auto-deploy locally via `pnpm dev`
21+
22+
## Corrections
23+
| 2026-02-10 | self | Ran `ls` before reading napkin | Always read `.claude/napkin.md` before any other command |

apps/web/app/challenges/[id]/activities/[activityId]/activity-detail-content.tsx

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from 'react';
44
import Link from 'next/link';
5+
import { useRouter } from 'next/navigation';
56
import { formatDistanceToNow, format } from 'date-fns';
67
import { useMutation, useQuery, usePaginatedQuery } from 'convex/react';
78
import { api } from '@repo/backend';
@@ -21,6 +22,7 @@ import {
2122
Shield,
2223
ThumbsUp,
2324
Trophy,
25+
Trash2,
2426
} from 'lucide-react';
2527

2628
import { ConvexError } from 'convex/values';
@@ -88,9 +90,14 @@ export function ActivityDetailContent({
8890
const [flagSubmitting, setFlagSubmitting] = useState(false);
8991
const [flagError, setFlagError] = useState<string | null>(null);
9092
const [flagSuccess, setFlagSuccess] = useState(false);
93+
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
94+
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
95+
const [deleteError, setDeleteError] = useState<string | null>(null);
9196

97+
const router = useRouter();
9298
const toggleLike = useMutation(api.mutations.likes.toggle);
9399
const flagActivity = useMutation(api.mutations.activities.flagActivity);
100+
const deleteActivity = useMutation(api.mutations.activities.remove);
94101

95102
const handleToggleLike = async () => {
96103
setPendingLike(true);
@@ -153,6 +160,20 @@ export function ActivityDetailContent({
153160
}
154161
};
155162

163+
const handleDelete = async () => {
164+
setDeleteSubmitting(true);
165+
setDeleteError(null);
166+
try {
167+
await deleteActivity({ activityId: activityId as Id<'activities'> });
168+
router.push(`/challenges/${challengeId}/dashboard`);
169+
} catch (err) {
170+
setDeleteError(
171+
err instanceof Error ? err.message : 'Failed to delete activity'
172+
);
173+
setDeleteSubmitting(false);
174+
}
175+
};
176+
156177
if (activityData === undefined) {
157178
return (
158179
<div className="flex items-center justify-center py-20">
@@ -183,7 +204,19 @@ export function ActivityDetailContent({
183204
);
184205
}
185206

186-
const { activity, user, activityType, challenge, likes, comments, likedByUser, mediaUrls, adminComment, isAdmin } =
207+
const {
208+
activity,
209+
user,
210+
activityType,
211+
challenge,
212+
likes,
213+
comments,
214+
likedByUser,
215+
mediaUrls,
216+
adminComment,
217+
isAdmin,
218+
isOwner,
219+
} =
187220
activityData;
188221

189222
const metrics = activity.metrics as Record<string, unknown> | undefined;
@@ -396,6 +429,18 @@ export function ActivityDetailContent({
396429
</Button>
397430
</DropdownMenuTrigger>
398431
<DropdownMenuContent align="end">
432+
{isOwner && (
433+
<DropdownMenuItem
434+
onClick={() => {
435+
setDeleteError(null);
436+
setShowDeleteDialog(true);
437+
}}
438+
className="text-destructive focus:text-destructive"
439+
>
440+
<Trash2 className="mr-2 h-4 w-4" />
441+
Delete activity
442+
</DropdownMenuItem>
443+
)}
399444
<DropdownMenuItem
400445
onClick={() => {
401446
setFlagSuccess(false);
@@ -490,6 +535,48 @@ export function ActivityDetailContent({
490535
</DialogFooter>
491536
</DialogContent>
492537
</Dialog>
538+
539+
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
540+
<DialogContent className="sm:max-w-md">
541+
<DialogHeader>
542+
<DialogTitle>Delete activity?</DialogTitle>
543+
<DialogDescription>
544+
This removes the activity from your logs and leaderboards. This action
545+
cannot be undone.
546+
</DialogDescription>
547+
</DialogHeader>
548+
{deleteError && (
549+
<Alert variant="destructive">
550+
<AlertDescription>{deleteError}</AlertDescription>
551+
</Alert>
552+
)}
553+
<DialogFooter className="flex-col gap-2 sm:flex-row sm:justify-end">
554+
<Button
555+
variant="outline"
556+
className="w-full sm:w-auto"
557+
onClick={() => setShowDeleteDialog(false)}
558+
disabled={deleteSubmitting}
559+
>
560+
Cancel
561+
</Button>
562+
<Button
563+
variant="destructive"
564+
className="w-full sm:w-auto"
565+
onClick={handleDelete}
566+
disabled={deleteSubmitting}
567+
>
568+
{deleteSubmitting ? (
569+
<>
570+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
571+
Deleting
572+
</>
573+
) : (
574+
'Delete activity'
575+
)}
576+
</Button>
577+
</DialogFooter>
578+
</DialogContent>
579+
</Dialog>
493580
</CardFooter>
494581
</Card>
495582

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,5 +1179,70 @@ describe('Activities Logic', () => {
11791179
expect(result2.bonusPoints).toBe(25);
11801180
expect(result2.pointsEarned).toBeCloseTo(123.25, 1);
11811181
});
1182+
1183+
it('should exclude deleted activities from drink scoring', async () => {
1184+
const testEmail = "test@example.com";
1185+
const userId = await createTestUser(t, { email: testEmail });
1186+
const tWithAuth = t.withIdentity({ subject: "test-user-id", email: testEmail });
1187+
const challengeId = await createTestChallenge(t, userId);
1188+
1189+
await t.run(async (ctx) => {
1190+
await ctx.db.insert("userChallenges", {
1191+
userId,
1192+
challengeId,
1193+
joinedAt: Date.now(),
1194+
totalPoints: 0,
1195+
currentStreak: 0,
1196+
modifierFactor: 1,
1197+
paymentStatus: "paid",
1198+
updatedAt: Date.now(),
1199+
});
1200+
});
1201+
1202+
const activityTypeId = await t.run(async (ctx) => {
1203+
return await ctx.db.insert("activityTypes", {
1204+
challengeId,
1205+
name: 'Drinks',
1206+
scoringConfig: {
1207+
unit: 'drinks',
1208+
pointsPerUnit: -1,
1209+
freebiesPerDay: 1,
1210+
},
1211+
contributesToStreak: false,
1212+
isNegative: true,
1213+
createdAt: Date.now(),
1214+
updatedAt: Date.now(),
1215+
});
1216+
});
1217+
1218+
const day = new Date('2024-01-15T12:00:00Z');
1219+
await t.run(async (ctx) => {
1220+
await ctx.db.insert("activities", {
1221+
userId,
1222+
challengeId,
1223+
activityTypeId,
1224+
loggedDate: day.getTime(),
1225+
metrics: { drinks: 2 },
1226+
source: "manual",
1227+
pointsEarned: -2,
1228+
flagged: false,
1229+
adminCommentVisibility: "internal",
1230+
resolutionStatus: "pending",
1231+
deletedAt: Date.now(),
1232+
createdAt: Date.now(),
1233+
updatedAt: Date.now(),
1234+
});
1235+
});
1236+
1237+
const result = await tWithAuth.mutation(api.mutations.activities.log, {
1238+
challengeId,
1239+
activityTypeId,
1240+
loggedDate: day.toISOString(),
1241+
metrics: { drinks: 2 },
1242+
source: "manual",
1243+
});
1244+
1245+
expect(result.pointsEarned).toBe(-1);
1246+
});
11821247
});
11831248
});

apps/web/tests/api/weekly-leaderboard.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ describe('getWeeklyCategoryLeaderboard', () => {
8989
activityTypeId: Id<'activityTypes'>,
9090
loggedDate: number,
9191
pointsEarned: number,
92+
deletedAt?: number,
9293
) => {
9394
return await t.run(async (ctx) => {
9495
return await ctx.db.insert('activities', {
@@ -102,6 +103,7 @@ describe('getWeeklyCategoryLeaderboard', () => {
102103
flagged: false,
103104
adminCommentVisibility: 'internal',
104105
resolutionStatus: 'pending',
106+
deletedAt,
105107
createdAt: Date.now(),
106108
updatedAt: Date.now(),
107109
});
@@ -219,6 +221,39 @@ describe('getWeeklyCategoryLeaderboard', () => {
219221
expect(strength!.entries[1].weeklyPoints).toBe(15);
220222
});
221223

224+
it('should ignore deleted activities in leaderboard totals', async () => {
225+
const { challengeId } = await setupChallenge();
226+
const cardioCategory = await createCategory('Cardio');
227+
const runningType = await createActivityType(challengeId, 'Running', cardioCategory);
228+
229+
const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice');
230+
const bob = await createParticipant(challengeId, 'bob@test.com', 'Bob');
231+
232+
await insertActivity(alice, challengeId, runningType, Date.UTC(2024, 0, 2), 30);
233+
await insertActivity(
234+
alice,
235+
challengeId,
236+
runningType,
237+
Date.UTC(2024, 0, 3),
238+
20,
239+
Date.now(),
240+
);
241+
await insertActivity(bob, challengeId, runningType, Date.UTC(2024, 0, 4), 40);
242+
243+
const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, {
244+
challengeId,
245+
weekNumber: 1,
246+
});
247+
248+
const cardio = result!.categories.find((c: any) => c.category.name === 'Cardio');
249+
expect(cardio).toBeDefined();
250+
expect(cardio!.entries).toHaveLength(2);
251+
expect(cardio!.entries[0].user.name).toBe('Bob');
252+
expect(cardio!.entries[0].weeklyPoints).toBe(40);
253+
expect(cardio!.entries[1].user.name).toBe('Alice');
254+
expect(cardio!.entries[1].weeklyPoints).toBe(30);
255+
});
256+
222257
it('should only include activities from the requested week', async () => {
223258
const { challengeId } = await setupChallenge();
224259
const category = await createCategory('Cardio');

packages/backend/_generated/api.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type * as actions_strava from "../actions/strava.js";
2020
import type * as auth from "../auth.js";
2121
import type * as http from "../http.js";
2222
import type * as index from "../index.js";
23+
import type * as lib_activityFilters from "../lib/activityFilters.js";
2324
import type * as lib_dateOnly from "../lib/dateOnly.js";
2425
import type * as lib_defaultEmailPlan from "../lib/defaultEmailPlan.js";
2526
import type * as lib_emailTemplate from "../lib/emailTemplate.js";
@@ -103,6 +104,7 @@ declare const fullApi: ApiFromModules<{
103104
auth: typeof auth;
104105
http: typeof http;
105106
index: typeof index;
107+
"lib/activityFilters": typeof lib_activityFilters;
106108
"lib/dateOnly": typeof lib_dateOnly;
107109
"lib/defaultEmailPlan": typeof lib_defaultEmailPlan;
108110
"lib/emailTemplate": typeof lib_emailTemplate;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const notDeleted = (q: any) => q.eq(q.field("deletedAt"), undefined);
2+
3+
export const isDeleted = (q: any) => q.neq(q.field("deletedAt"), undefined);

packages/backend/lib/scoring.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { MutationCtx, QueryCtx } from "../_generated/server";
22
import { Id, Doc } from "../_generated/dataModel";
3+
import { notDeleted } from "./activityFilters";
34

45
interface BonusThreshold {
56
metric: string;
@@ -73,7 +74,12 @@ async function calculateDrinkPoints(
7374
.gte("loggedDate", startOfDayUtc)
7475
.lt("loggedDate", endOfDayUtc)
7576
)
76-
.filter((q) => q.eq(q.field("activityTypeId"), activityType._id))
77+
.filter((q) =>
78+
q.and(
79+
q.eq(q.field("activityTypeId"), activityType._id),
80+
notDeleted(q)
81+
)
82+
)
7783
.collect();
7884

7985
const existingTotal = existingDrinksLogs.reduce((sum, entry) => {

0 commit comments

Comments
 (0)