Skip to content

Commit 2b5d4d3

Browse files
committed
feat(exercises): improve attempt time tracking and UI
- Add resetTimeSpent action to exercise store - Update analytics to track time only during active attempts - Improve exercise stats UI with borders and dividers - Fix choice selection logic in exercise answers - Update dependencies including @types/node to 25.0.8 - Add vitest config for backend testing - Document exercise time tracking architecture in utils - Refactor attempt duration calculation to use wall-clock time
1 parent 3ad052e commit 2b5d4d3

22 files changed

Lines changed: 320 additions & 209 deletions

File tree

AGENTS.md

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
- `pnpm --filter www dev` - Start single app
99
- `pnpm build` - Build all packages/apps
1010
- `pnpm test` - Run all tests (Vitest)
11-
- `pnpm --filter www test` - Run www tests, use `vitest run <file>` for single test
11+
- `pnpm --filter www test` - Run www tests
12+
- `pnpm --filter www exec vitest run <file-path>` - Run single test file
1213
- `pnpm lint` - Ultracite check
1314
- `pnpm format` - Ultracite fix
1415

@@ -27,15 +28,30 @@
2728

2829
- Check existing patterns before creating new components
2930
- Use shared packages (`@repo/design-system`, `@repo/ai`, etc.) instead of duplicating code
30-
- For MDX content: use `InlineMath` for numbers and math, inline code for programming elements
31+
- For MDX content: use `InlineMath` for numbers/math, inline code for programming
3132
- Convex backend in `packages/backend/convex/`, use auth helpers never `ctx.auth` directly, see `packages/backend/convex/lib` folder for shared utils functions
33+
- MDX components available without import: `BlockMath`, `InlineMath`, `CodeBlock`, `MathContainer`, `Mermaid`
34+
- Import required for: `NumberLine`, `LineEquation` from `@repo/design-system/components/contents/*`
35+
- Import aliases: `@/` for app-level imports, `@repo/*` for workspace packages
36+
- Allowed Biome exceptions: namespace imports, barrel files, higher cognitive complexity (40)
3237
- Run lint/test after all changes
3338

34-
# TypeScript Style
39+
## MDX Content Guidelines
40+
41+
- Headings: start from h2 only, max h3 depth, descriptive (not "Step 1"), no symbols, no InlineMath
42+
- Code vs Math: inline code (`print()`, `const x`) for programming, InlineMath (`<InlineMath math="5" />`) for math
43+
- Math formatting: all math in InlineMath/BlockMath, use `MathContainer` to wrap consecutive blocks, units in `\text{}`
44+
- CodeBlock: required `data` prop with unique languages per component, supports multiple file tabs
45+
- NumberLine: import required, use InlineMath for all numbers, `startLabel`/`endLabel` for fractions
46+
- 3D visualizations: generate points via Array.from() with math calculations (never hard-code), use `getColor()` for colors (not randomColor)
47+
- Lists: use hyphens `-`, no nested lists, proper indentation
48+
- Line breaks: blank line between text paragraphs and math blocks
49+
50+
## TypeScript Style
3551

3652
NEVER USE ASSERTION! Good typescript is when you can write code like javascript but still type safe.
3753

38-
# Ultracite Code Standards
54+
## Ultracite Code Standards
3955

4056
This project uses **Ultracite**, a zero-config Biome preset that enforces strict code quality standards through automated formatting and linting.
4157

@@ -124,28 +140,20 @@ Write code that is **accessible, performant, type-safe, and maintainable**. Focu
124140

125141
### Framework-Specific Guidance
126142

127-
**Next.js:**
128-
129-
- Use Next.js `<Image>` component for images
130-
- Use `next/head` or App Router metadata API for head elements
131-
- Use Server Components for async data fetching instead of async Client Components
132-
133-
**React 19+:**
134-
135-
- Use ref as a prop instead of `React.forwardRef`
136-
137-
**Solid/Svelte/Vue/Qwik:**
138-
139-
- Use `class` and `for` attributes (not `className` or `htmlFor`)
143+
- **Next.js**: Use `<Image>` component, App Router metadata API, Server Components for async data
144+
- **React 19+**: Use ref as prop instead of `React.forwardRef`
140145

141146
---
142147

143148
## Testing
144149

150+
- Framework: Vitest with shared config from `@repo/testing`
145151
- Write assertions inside `it()` or `test()` blocks
152+
- Use `describe()` for grouping related tests
146153
- Avoid done callbacks in async tests - use async/await instead
147154
- Don't use `.only` or `.skip` in committed code
148155
- Keep test suites reasonably flat - avoid excessive `describe` nesting
156+
- Test files: `__tests__/` directories or `.test.ts`/.tsx naming
149157

150158
## When Biome Can't Help
151159

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
},
3232
"devDependencies": {
3333
"@repo/typescript-config": "workspace:*",
34-
"@types/node": "25.0.7",
34+
"@types/node": "25.0.8",
3535
"@types/react": "19.2.8",
3636
"typescript": "^5.9.3",
3737
"@vitest/coverage-istanbul": "^4.0.17",

apps/email/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"devDependencies": {
1919
"@react-email/preview-server": "^5.0.5",
2020
"@repo/typescript-config": "workspace:*",
21-
"@types/node": "25.0.7",
21+
"@types/node": "25.0.8",
2222
"@types/react": "19.2.8",
2323
"typescript": "^5.9.3"
2424
}

apps/mcp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
},
2828
"devDependencies": {
2929
"@repo/typescript-config": "workspace:*",
30-
"@types/node": "25.0.7",
30+
"@types/node": "25.0.8",
3131
"@types/react": "19.2.8",
3232
"typescript": "^5.9.3"
3333
}
Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
"use client";
22

3-
import {
4-
useDebouncedCallback,
5-
useIntersection,
6-
useInterval,
7-
} from "@mantine/hooks";
3+
import { useIntersection, useInterval } from "@mantine/hooks";
84
import type { ReactNode } from "react";
95
import { useEffect, useRef } from "react";
6+
import { useAttempt } from "@/lib/context/use-attempt";
107
import { useExercise } from "@/lib/context/use-exercise";
118

129
export function QuestionAnalytics({
@@ -16,36 +13,30 @@ export function QuestionAnalytics({
1613
exerciseNumber: number;
1714
children: ReactNode;
1815
}) {
16+
const attempt = useAttempt((state) => state.attempt);
17+
1918
const ref = useIntersection({ threshold: 0.75 });
2019
const isActive = ref.entry?.isIntersecting ?? false;
2120
const timeCounterRef = useRef(0);
2221

2322
const setTimeSpent = useExercise((state) => state.setTimeSpent);
2423

25-
const debouncedPersist = useDebouncedCallback((time: number) => {
26-
setTimeSpent(exerciseNumber, time);
27-
}, 1000);
24+
const hasActiveAttempt = attempt?.status === "in-progress";
2825

2926
const interval = useInterval(() => {
30-
if (isActive) {
27+
if (isActive && hasActiveAttempt) {
3128
timeCounterRef.current += 1;
32-
debouncedPersist(timeCounterRef.current);
29+
setTimeSpent(exerciseNumber, timeCounterRef.current);
3330
}
3431
}, 1000);
3532

3633
useEffect(() => {
37-
if (isActive) {
34+
if (isActive && hasActiveAttempt) {
3835
interval.start();
3936
} else {
4037
interval.stop();
4138
}
42-
}, [isActive, interval]);
43-
44-
useEffect(() => {
45-
return () => {
46-
debouncedPersist.flush();
47-
};
48-
}, [debouncedPersist]);
39+
}, [isActive, hasActiveAttempt, interval]);
4940

5041
return <div ref={ref.ref}>{children}</div>;
5142
}

apps/www/app/[locale]/(study)/(main)/(contents)/exercises/[category]/[type]/[material]/[...slug]/attempt-complete-button.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function CompleteExerciseButton() {
2424

2525
const showStats = useExercise((state) => state.showStats);
2626
const setShowStats = useExercise((state) => state.setShowStats);
27+
const resetTimeSpent = useExercise((state) => state.resetTimeSpent);
2728

2829
const user = useUser((state) => state.user);
2930
const attempt = useAttempt((state) => state.attempt);
@@ -52,6 +53,7 @@ export function CompleteExerciseButton() {
5253
try {
5354
await completeAttempt({ attemptId: attempt._id });
5455
setOpen(false);
56+
resetTimeSpent();
5557
setShowStats(true);
5658
} catch {
5759
toast.error(t("complete-exercise-error"), {
@@ -62,7 +64,7 @@ export function CompleteExerciseButton() {
6264
};
6365

6466
return (
65-
<ButtonGroup>
67+
<ButtonGroup className="divide-x divide-primary-foreground/20">
6668
<Button
6769
disabled={isPending}
6870
onClick={() => setOpen(true)}

apps/www/app/[locale]/(study)/(main)/(contents)/exercises/[category]/[type]/[material]/[...slug]/attempt-start-button.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export function StartExerciseButton({
7474
const slug = useExercise((state) => state.slug);
7575
const showStats = useExercise((state) => state.showStats);
7676
const setShowStats = useExercise((state) => state.setShowStats);
77+
const resetTimeSpent = useExercise((state) => state.resetTimeSpent);
7778

7879
const user = useUser((state) => state.user);
7980
const startAttempt = useMutation(api.exercises.mutations.startAttempt);
@@ -103,6 +104,7 @@ export function StartExerciseButton({
103104
timeLimit,
104105
});
105106
setOpen(false);
107+
resetTimeSpent();
106108
setShowStats(true);
107109
toast.success(t("start-exercise-success"), {
108110
position: "bottom-center",
@@ -123,7 +125,7 @@ export function StartExerciseButton({
123125
form.handleSubmit();
124126
}}
125127
>
126-
<ButtonGroup>
128+
<ButtonGroup className="divide-x divide-primary-foreground/20">
127129
<Button onClick={() => setOpen(true)} type="button">
128130
<HugeIcons icon={StartUp02Icon} />
129131
{t("start-exercise-title")}

apps/www/app/[locale]/(study)/(main)/(contents)/exercises/[category]/[type]/[material]/[...slug]/attempt-stats.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ function StatsResult({ attempt }: { attempt: Doc<"exerciseAttempts"> }) {
4949
const t = useTranslations("Exercises");
5050

5151
return (
52-
<section className="space-y-3 rounded-md bg-muted/20 p-4">
52+
<section className="space-y-3 rounded-lg border border-border/50 bg-muted/20 p-4">
5353
<div className="grid grid-cols-2 gap-3">
5454
<div className="flex flex-col gap-1">
5555
<div className="text-muted-foreground text-sm">{t("score")}</div>
@@ -120,7 +120,7 @@ function StatsProgress({
120120
const progress = totalCount > 0 ? (answeredCount / totalCount) * 100 : 0;
121121

122122
return (
123-
<div className="flex flex-col gap-4 rounded-md bg-muted/20 p-4">
123+
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-muted/20 p-4">
124124
<div className="flex items-center justify-between text-sm">
125125
<Badge variant="default-subtle">
126126
<HugeIcons

apps/www/app/[locale]/(study)/(main)/(contents)/exercises/[category]/[type]/[material]/[...slug]/choices.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
4040
(a) => a.exerciseNumber === exerciseNumber
4141
);
4242

43-
function handleSubmit(choice: ExercisesChoices[keyof ExercisesChoices][0]) {
43+
function handleSubmit({
44+
choice,
45+
index,
46+
}: {
47+
choice: ExercisesChoices[keyof ExercisesChoices][number];
48+
index: number;
49+
}) {
4450
if (!attempt) {
4551
toast.info(t("attempt-not-found"), { position: "bottom-center" });
4652
return;
@@ -57,7 +63,7 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
5763
await submitAttempt({
5864
attemptId: attempt._id,
5965
exerciseNumber,
60-
selectedOptionId: choice.label,
66+
selectedOptionId: index.toString(),
6167
textAnswer: choice.label,
6268
isCorrect: choice.value,
6369
timeSpent,
@@ -70,9 +76,12 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
7076

7177
return (
7278
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
73-
{choices.map((choice) => {
79+
{choices.map((choice, index) => {
7480
let variant: ComponentProps<typeof Button>["variant"] = "outline";
75-
const checked = currentAnswer?.selectedOptionId === choice.label;
81+
82+
const checked =
83+
currentAnswer?.selectedOptionId === index.toString() ||
84+
currentAnswer?.textAnswer === choice.label;
7685

7786
if (checked) {
7887
variant = "default-outline";
@@ -97,12 +106,12 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) {
97106
key={choice.label}
98107
>
99108
<Checkbox
100-
checked={currentAnswer?.selectedOptionId === choice.label}
109+
checked={checked}
101110
className="cursor-pointer"
102111
disabled={isPending}
103112
onCheckedChange={(checked) => {
104113
if (checked) {
105-
handleSubmit(choice);
114+
handleSubmit({ choice, index });
106115
}
107116
}}
108117
/>

apps/www/components/shared/open-content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { getGithubUrl } from "@/lib/utils/github";
3232

3333
export function OpenContent({ slug }: { slug: string }) {
3434
return (
35-
<div className="inline-flex divide-x divide-secondary-foreground/10 rounded-md shadow-xs rtl:space-x-reverse">
35+
<div className="inline-flex divide-x divide-secondary-foreground/20 rounded-md shadow-xs rtl:space-x-reverse">
3636
<LLmCopyButton slug={slug} />
3737
<ViewOptions slug={slug} />
3838
</div>

0 commit comments

Comments
 (0)