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
39 changes: 29 additions & 10 deletions backend/src/api/controllers/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export async function getResults(
limit,
offset,
});

void addLog(
"user_results_requested",
{
Expand All @@ -132,7 +133,10 @@ export async function getResults(
uid,
);

return new MonkeyResponse("Results retrieved", replaceObjectIds(results));
return new MonkeyResponse(
"Results retrieved",
replaceObjectIds(results) as GetResultsResponse["data"],
);
}

export async function getResultById(
Expand All @@ -142,15 +146,23 @@ export async function getResultById(
const { resultId } = req.params;

const result = await ResultDAL.getResult(uid, resultId);
return new MonkeyResponse("Result retrieved", replaceObjectId(result));

return new MonkeyResponse(
"Result retrieved",
replaceObjectId(result) as GetResultByIdResponse["data"],
);
}

export async function getLastResult(
req: MonkeyRequest,
): Promise<GetLastResultResponse> {
const { uid } = req.ctx.decodedToken;
const result = await ResultDAL.getLastResult(uid);
return new MonkeyResponse("Result retrieved", replaceObjectId(result));

return new MonkeyResponse(
"Result retrieved",
replaceObjectId(result) as GetLastResultResponse["data"],
);
}

export async function deleteAll(req: MonkeyRequest): Promise<MonkeyResponse> {
Expand Down Expand Up @@ -201,6 +213,8 @@ export async function addResult(
const completedEvent = req.body.result;
completedEvent.uid = uid;

const isPractice = completedEvent.mode === "practice";

Comment on lines +216 to +217
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isPractice currently only skips PB checks + DB insert, but practice is supposed to not affect stats/leaderboards. Later in this handler you still update typing stats (updateTypingStats/PublicDAL.updateStats), streak, testActivity, and potentially daily leaderboards (if config rules match practice). Wrap those side effects in if (!isPractice) too (or early-return with a practice-specific response) to match the mode contract.

Copilot uses AI. Check for mistakes.
Comment on lines +216 to +217
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add/extend controller tests for mode: "practice" to assert no persistence and no stat updates (eg ResultDAL.addResult, UserDAL.updateTypingStats, PublicDAL.updateStats, UserDAL.updateStreak, incrementTestActivity, daily leaderboard). Current tests already mock these calls for normal modes, so adding a practice case should be straightforward.

Copilot uses AI. Check for mistakes.
if (isTestTooShort(completedEvent)) {
const status = MonkeyStatusCodes.TEST_TOO_SHORT;
throw new MonkeyError(status.code, status.message);
Expand Down Expand Up @@ -437,7 +451,7 @@ export async function addResult(
let isPb = false;
let tagPbs: string[] = [];

if (!completedEvent.bailedOut) {
if (!completedEvent.bailedOut && !isPractice) {
[isPb, tagPbs] = await Promise.all([
UserDAL.checkIfPb(uid, user, completedEvent),
UserDAL.checkIfTagPb(uid, user, completedEvent),
Expand Down Expand Up @@ -629,27 +643,32 @@ export async function addResult(
dbresult.keyDurationStats = keyDurationStats;
}

const addedResult = await ResultDAL.addResult(uid, dbresult);
let insertedId: string | undefined;

if (!isPractice) {
const addedResult = await ResultDAL.addResult(uid, dbresult);
insertedId = addedResult.insertedId.toHexString();
}

await UserDAL.incrementXp(uid, xpGained.xp);
await UserDAL.incrementTestActivity(user, completedEvent.timestamp);

if (isPb) {
if (isPb && insertedId !== undefined) {
void addLog(
"user_new_pb",
`${completedEvent.mode + " " + completedEvent.mode2} ${
`${completedEvent.mode} ${completedEvent.mode2} ${
completedEvent.wpm
} ${completedEvent.acc}% ${completedEvent.rawWpm} ${
completedEvent.consistency
}% (${addedResult.insertedId})`,
}% (${insertedId})`,
uid,
);
}

const data: PostResultResponse = {
insertedId: insertedId ?? "practice",
isPb,
Comment on lines 668 to 670
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning insertedId: "practice" will be treated as a real result id by the frontend (it sets edit-tags data-result-id and saves a snapshot with _id = insertedId), leading to broken tag editing / local history for practice runs. Either persist a real result id (and exclude it from stats/history), or change the response/consumer so practice runs don’t provide/use an id.

Copilot uses AI. Check for mistakes.
tagPbs,
insertedId: addedResult.insertedId.toHexString(),
xp: xpGained.xp,
dailyXpBonus: xpGained.dailyBonus ?? false,
xpBreakdown: xpGained.breakdown ?? {},
Expand Down Expand Up @@ -703,7 +722,7 @@ async function calculateXp(
funboxBonus: funboxBonusConfiguration,
} = xpConfiguration;

if (mode === "zen" || !enabled) {
if (mode === "zen" || mode === "practice" || !enabled) {
return {
xp: 0,
};
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/ts/constants/default-result-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const object: ResultFilters = {
quote: true,
zen: true,
custom: true,
practice: false, // practice results are never part of stats
},

words: {
"10": true,
"25": true,
Expand Down
5 changes: 5 additions & 0 deletions packages/schemas/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const PersonalBestSchema = z.object({
export type PersonalBest = z.infer<typeof PersonalBestSchema>;

//used by user and config

export const PersonalBestsSchema = z.object({
time: z.record(
StringNumberSchema.describe("Number of seconds as string"),
Expand All @@ -34,7 +35,11 @@ export const PersonalBestsSchema = z.object({
quote: z.record(StringNumberSchema, z.array(PersonalBestSchema)),
custom: z.record(z.literal("custom"), z.array(PersonalBestSchema)),
zen: z.record(z.literal("zen"), z.array(PersonalBestSchema)),

// practice mode: intentionally empty, never tracked
practice: z.never(),
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

practice: z.never() makes PersonalBestsSchema impossible to satisfy (key becomes required but can never validate), so parsing existing user/tag PB objects will start failing. Make the key optional while still adding it to ModeSchema (eg z.never().optional()), or model it as optional/undefined and keep PB storage unchanged.

Suggested change
practice: z.never(),
practice: z.never().optional(),

Copilot uses AI. Check for mistakes.
});

export type PersonalBests = z.infer<typeof PersonalBestsSchema>;

export const DefaultWordsModeSchema = z.union([
Expand Down
Loading