rogasper commited on
Commit
dee29ff
·
1 Parent(s): 72e7de2

feat: refactor AttemptResultComponent to calculate percentage based on question statistics instead of attempt scores. Update integration tests to validate maxScore calculation across multiple sections. Ensure proper type handling for question counts in attempt router, enhancing data integrity and performance.

Browse files
apps/web/src/routes/attempt.$id.tsx CHANGED
@@ -60,10 +60,6 @@ function AttemptResultComponent() {
60
  onSuccess: () => packageRatingQuery.refetch(),
61
  });
62
 
63
- const percentage = attempt?.maxScore && attempt.maxScore > 0
64
- ? Math.round(((attempt.totalScore ?? 0) / attempt.maxScore) * 100)
65
- : 0;
66
-
67
  const durationSec = attempt?.finishedAt && attempt.startedAt
68
  ? Math.round((new Date(attempt.finishedAt).getTime() - new Date(attempt.startedAt).getTime()) / 1000)
69
  : 0;
@@ -87,6 +83,17 @@ function AttemptResultComponent() {
87
  );
88
  }, [attempt]);
89
 
 
 
 
 
 
 
 
 
 
 
 
90
  // ── Filtered questions ──
91
  const partialCount = useMemo(
92
  () => allQuestions.filter(({ ans }) => ans?.partialScore != null && ans?.partialScore < 100).length,
@@ -186,7 +193,7 @@ function AttemptResultComponent() {
186
  {percentage}%
187
  </span>
188
  <span className="text-xs text-[var(--matcha-700)] font-medium">
189
- {attempt.totalScore ?? 0}/{attempt.maxScore ?? 0}
190
  </span>
191
  </div>
192
 
@@ -207,15 +214,15 @@ function AttemptResultComponent() {
207
  </span>
208
  <span className="flex items-center gap-1">
209
  <MaterialIcon name="quiz" className="text-sm" />
210
- {attempt.maxScore ?? 0} soal
211
  </span>
212
  <span className="flex items-center gap-1">
213
  <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
214
- {attempt.totalScore ?? 0} benar
215
  </span>
216
  <span className="flex items-center gap-1">
217
  <MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
218
- {(attempt.maxScore ?? 0) - (attempt.totalScore ?? 0)} salah
219
  </span>
220
  </div>
221
 
 
60
  onSuccess: () => packageRatingQuery.refetch(),
61
  });
62
 
 
 
 
 
63
  const durationSec = attempt?.finishedAt && attempt.startedAt
64
  ? Math.round((new Date(attempt.finishedAt).getTime() - new Date(attempt.startedAt).getTime()) / 1000)
65
  : 0;
 
83
  );
84
  }, [attempt]);
85
 
86
+ const questionStats = useMemo(() => {
87
+ const total = allQuestions.length;
88
+ const correct = allQuestions.filter(({ ans }) => ans?.isCorrect === true).length;
89
+ const wrong = allQuestions.filter(({ ans }) => ans?.isCorrect === false).length;
90
+ return { total, correct, wrong };
91
+ }, [allQuestions]);
92
+
93
+ const percentage = questionStats.total > 0
94
+ ? Math.round((questionStats.correct / questionStats.total) * 100)
95
+ : 0;
96
+
97
  // ── Filtered questions ──
98
  const partialCount = useMemo(
99
  () => allQuestions.filter(({ ans }) => ans?.partialScore != null && ans?.partialScore < 100).length,
 
193
  {percentage}%
194
  </span>
195
  <span className="text-xs text-[var(--matcha-700)] font-medium">
196
+ {questionStats.correct}/{questionStats.total}
197
  </span>
198
  </div>
199
 
 
214
  </span>
215
  <span className="flex items-center gap-1">
216
  <MaterialIcon name="quiz" className="text-sm" />
217
+ {questionStats.total} soal
218
  </span>
219
  <span className="flex items-center gap-1">
220
  <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
221
+ {questionStats.correct} benar
222
  </span>
223
  <span className="flex items-center gap-1">
224
  <MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
225
+ {questionStats.wrong} salah
226
  </span>
227
  </div>
228
 
packages/api/src/__tests__/attempt.integration.test.ts CHANGED
@@ -175,6 +175,85 @@ describe("attempt router", () => {
175
  expect(result.percentage).toBe(33);
176
  });
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  it("finish rejects attempts under 5 seconds", async () => {
179
  const { caller } = await createUserAndCaller("fastfinish");
180
  const { attemptId } = await caller.start({ packageId });
 
175
  expect(result.percentage).toBe(33);
176
  });
177
 
178
+ it("finish sums maxScore across multiple sections", { timeout: 30000 }, async () => {
179
+ const ownerId = "multi-section-owner";
180
+ await testDb.insert(schema.user).values({
181
+ id: ownerId,
182
+ name: "Multi Section Owner",
183
+ email: `multi-section-${Date.now()}@test.com`,
184
+ }).onConflictDoNothing();
185
+
186
+ const [pkg] = await testDb.insert(schema.testPackage).values({
187
+ title: "Multi Section Test",
188
+ examTypeId: "IELTS",
189
+ creatorUserId: ownerId,
190
+ isPublic: true,
191
+ }).returning();
192
+
193
+ const sections = await testDb.insert(schema.packageSection).values([
194
+ {
195
+ packageId: pkg.id,
196
+ sectionTypeId: "READING",
197
+ title: "Reading Part 1",
198
+ orderIndex: 0,
199
+ },
200
+ {
201
+ packageId: pkg.id,
202
+ sectionTypeId: "READING",
203
+ title: "Reading Part 2",
204
+ orderIndex: 1,
205
+ },
206
+ ]).returning();
207
+
208
+ const makeQuestion = (idx: number) => ({
209
+ examTypeId: "IELTS",
210
+ sectionTypeId: "READING",
211
+ format: "multiple_choice" as const,
212
+ passageText: `Passage ${idx} `.repeat(10),
213
+ questionText: `Question ${idx}?`,
214
+ options: [
215
+ { key: "A", text: "Option A" },
216
+ { key: "B", text: "Option B" },
217
+ { key: "C", text: "Option C" },
218
+ { key: "D", text: "Option D" },
219
+ ],
220
+ correctAnswer: "A",
221
+ explanation: "Because A is correct",
222
+ difficulty: 2,
223
+ skillTags: ["main_idea"],
224
+ creatorUserId: ownerId,
225
+ isPublic: true,
226
+ });
227
+
228
+ const sectionOneQuestions = await testDb.insert(schema.question).values(
229
+ Array.from({ length: 10 }, (_, idx) => makeQuestion(idx + 1)),
230
+ ).returning();
231
+ const sectionTwoQuestions = await testDb.insert(schema.question).values(
232
+ Array.from({ length: 10 }, (_, idx) => makeQuestion(idx + 11)),
233
+ ).returning();
234
+
235
+ await testDb.insert(schema.sectionQuestion).values([
236
+ ...sectionOneQuestions.map((q, orderIndex) => ({
237
+ sectionId: sections[0]!.id,
238
+ questionId: q.id,
239
+ orderIndex,
240
+ })),
241
+ ...sectionTwoQuestions.map((q, orderIndex) => ({
242
+ sectionId: sections[1]!.id,
243
+ questionId: q.id,
244
+ orderIndex,
245
+ })),
246
+ ]);
247
+
248
+ const { caller } = await createUserAndCaller("multisection");
249
+ const { attemptId } = await caller.start({ packageId: pkg.id });
250
+ await Bun.sleep(4500);
251
+
252
+ const result = await caller.finish({ attemptId });
253
+ expect(result.maxScore).toBe(20);
254
+ expect(typeof result.maxScore).toBe("number");
255
+ });
256
+
257
  it("finish rejects attempts under 5 seconds", async () => {
258
  const { caller } = await createUserAndCaller("fastfinish");
259
  const { attemptId } = await caller.start({ packageId });
packages/api/src/routers/attempt.ts CHANGED
@@ -473,14 +473,14 @@ export const attemptRouter = router({
473
  const counts = await db
474
  .select({
475
  sectionId: sectionQuestion.sectionId,
476
- count: sql<number>`count(*)`,
477
  })
478
  .from(sectionQuestion)
479
  .where(inArray(sectionQuestion.sectionId, psIds))
480
  .groupBy(sectionQuestion.sectionId);
481
 
482
  for (const c of counts) {
483
- questionCounts.set(c.sectionId, c.count);
484
  }
485
  }
486
 
@@ -528,7 +528,7 @@ export const attemptRouter = router({
528
  .where(eq(answer.id, a.answerId));
529
  }
530
 
531
- const sectionMax = questionCounts.get(pkgSec.id) ?? secAnswers.length;
532
  const sectionTimeSpent = secAnswers.reduce((sum, a) => sum + (a.timeSpentSec ?? 0), 0);
533
 
534
  await db
@@ -554,7 +554,7 @@ export const attemptRouter = router({
554
  // Total questions in package
555
  let totalQuestions = 0;
556
  for (const count of questionCounts.values()) {
557
- totalQuestions += count;
558
  }
559
 
560
  await db
 
473
  const counts = await db
474
  .select({
475
  sectionId: sectionQuestion.sectionId,
476
+ count: sql<number>`count(*)::int`,
477
  })
478
  .from(sectionQuestion)
479
  .where(inArray(sectionQuestion.sectionId, psIds))
480
  .groupBy(sectionQuestion.sectionId);
481
 
482
  for (const c of counts) {
483
+ questionCounts.set(c.sectionId, Number(c.count));
484
  }
485
  }
486
 
 
528
  .where(eq(answer.id, a.answerId));
529
  }
530
 
531
+ const sectionMax = Number(questionCounts.get(pkgSec.id) ?? secAnswers.length);
532
  const sectionTimeSpent = secAnswers.reduce((sum, a) => sum + (a.timeSpentSec ?? 0), 0);
533
 
534
  await db
 
554
  // Total questions in package
555
  let totalQuestions = 0;
556
  for (const count of questionCounts.values()) {
557
+ totalQuestions += Number(count);
558
  }
559
 
560
  await db