Skip to content

Commit d01d5e4

Browse files
authored
Merge pull request #10 from prithaxdev/sprint-3/fix-review-scheduling-and-ux
fix: quality-adjusted SM-2 first intervals and review UX freeze
2 parents 6349c5b + e9675ec commit d01d5e4

5 files changed

Lines changed: 79 additions & 26 deletions

File tree

apps/api/app/algorithms/sm2.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ def sm2_schedule(
4444
new_repetitions = repetitions + 1
4545

4646
if repetitions == 0:
47-
new_interval = 1
47+
# Quality-adjusted first interval: Easy gets 4 days, Hard gets 2 days.
48+
# Standard SM-2 fixes this at 1, but that makes every first review
49+
# feel identical regardless of confidence — poor UX.
50+
new_interval = 4 if quality >= 4 else 2
4851
elif repetitions == 1:
4952
new_interval = 6
5053
else:

apps/api/tests/test_sm2.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@
88
# Basic scheduling progression
99
# ---------------------------------------------------------------------------
1010

11-
def test_first_review_easy_interval_is_one():
12-
"""First review (repetitions=0) always produces interval=1 regardless of quality."""
11+
def test_first_review_easy_gives_four_day_interval():
12+
"""First review (repetitions=0) with quality>=4 (Easy) gives interval=4."""
1313
interval, reps, ef = sm2_schedule(quality=5, repetitions=0, ease_factor=2.5, interval=1)
14-
assert interval == 1
14+
assert interval == 4
15+
assert reps == 1
16+
17+
18+
def test_first_review_hard_gives_two_day_interval():
19+
"""First review (repetitions=0) with quality==3 (Hard) gives interval=2."""
20+
interval, reps, ef = sm2_schedule(quality=3, repetitions=0, ease_factor=2.5, interval=1)
21+
assert interval == 2
1522
assert reps == 1
1623

1724

@@ -111,7 +118,7 @@ def test_simulate_30_days_of_reviews():
111118
assert ease_factor >= 1.3, f"EF below minimum at step {step}"
112119
assert repetitions == step + 1, f"Unexpected reps at step {step}"
113120

114-
# After the fixed steps (1 and 6), each interval must grow
121+
# After the quality-adjusted first step (4) and the fixed second step (6), intervals grow
115122
if step >= 2:
116123
assert interval >= prev_interval, (
117124
f"Interval did not grow at step {step}: {interval} < {prev_interval}"

apps/web/components/review-card.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ interface ReviewCardProps {
1313
index: number
1414
total: number
1515
onRate: (rating: 1 | 3 | 5) => void
16-
isSubmitting: boolean
16+
submittingRating: 1 | 3 | 5 | null
1717
}
1818

19-
export function ReviewCard({ snippet, index, total, onRate, isSubmitting }: ReviewCardProps) {
19+
export function ReviewCard({ snippet, index, total, onRate, submittingRating }: ReviewCardProps) {
20+
const isSubmitting = submittingRating !== null
2021
const [revealed, setReveal] = useState(false)
2122

2223
return (
@@ -120,20 +121,23 @@ export function ReviewCard({ snippet, index, total, onRate, isSubmitting }: Revi
120121
sublabel="Start over"
121122
onClick={() => onRate(1)}
122123
disabled={isSubmitting}
124+
isActive={submittingRating === 1}
123125
variant="forgot"
124126
/>
125127
<RatingButton
126128
label="Hard"
127129
sublabel="Got it, barely"
128130
onClick={() => onRate(3)}
129131
disabled={isSubmitting}
132+
isActive={submittingRating === 3}
130133
variant="hard"
131134
/>
132135
<RatingButton
133136
label="Easy"
134137
sublabel="Got it!"
135138
onClick={() => onRate(5)}
136139
disabled={isSubmitting}
140+
isActive={submittingRating === 5}
137141
variant="easy"
138142
/>
139143
</div>
@@ -148,12 +152,14 @@ function RatingButton({
148152
sublabel,
149153
onClick,
150154
disabled,
155+
isActive,
151156
variant,
152157
}: {
153158
label: string
154159
sublabel: string
155160
onClick: () => void
156161
disabled: boolean
162+
isActive: boolean
157163
variant: "forgot" | "hard" | "easy"
158164
}) {
159165
const styles = {
@@ -170,12 +176,29 @@ function RatingButton({
170176
className={cn(
171177
"flex flex-col items-center gap-0.5 rounded-xl border px-3 py-3.5",
172178
"active:scale-[0.96] transition-[transform,background-color,opacity] duration-100",
173-
"disabled:pointer-events-none disabled:opacity-50",
179+
"disabled:pointer-events-none",
180+
isActive ? "opacity-100" : "disabled:opacity-40",
174181
styles[variant],
175182
)}
176183
>
177-
<span className="text-sm font-semibold">{label}</span>
178-
<span className="text-[11px] opacity-70">{sublabel}</span>
184+
{isActive ? (
185+
<svg
186+
className="size-4 animate-spin"
187+
viewBox="0 0 24 24"
188+
fill="none"
189+
aria-label="Processing"
190+
>
191+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2.5" />
192+
<path
193+
className="opacity-75"
194+
fill="currentColor"
195+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
196+
/>
197+
</svg>
198+
) : (
199+
<span className="text-sm font-semibold">{label}</span>
200+
)}
201+
<span className="text-[11px] opacity-70">{isActive ? "Saving…" : sublabel}</span>
179202
</button>
180203
)
181204
}

apps/web/components/review-session.tsx

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,29 @@ export function ReviewSession() {
1313

1414
const [index, setIndex] = useState(0)
1515
const [done, setDone] = useState(false)
16+
const [submittingRating, setSubmittingRating] = useState<1 | 3 | 5 | null>(null)
17+
const [submitError, setSubmitError] = useState<string | null>(null)
1618

1719
const handleRate = async (snippet: Snippet, rating: 1 | 3 | 5) => {
18-
await submitReview.mutateAsync({
19-
snippetId: snippet.id,
20-
rating,
21-
currentEase: snippet.ease_factor,
22-
currentInterval: snippet.interval_days,
23-
currentReps: snippet.repetitions,
24-
})
25-
26-
if (index + 1 >= due.length) {
27-
setDone(true)
28-
} else {
29-
setIndex((i) => i + 1)
20+
setSubmittingRating(rating)
21+
setSubmitError(null)
22+
try {
23+
await submitReview.mutateAsync({
24+
snippetId: snippet.id,
25+
rating,
26+
currentEase: snippet.ease_factor,
27+
currentInterval: snippet.interval_days,
28+
currentReps: snippet.repetitions,
29+
})
30+
if (index + 1 >= due.length) {
31+
setDone(true)
32+
} else {
33+
setIndex((i) => i + 1)
34+
}
35+
} catch (err) {
36+
setSubmitError(err instanceof Error ? err.message : "Something went wrong. Try again.")
37+
} finally {
38+
setSubmittingRating(null)
3039
}
3140
}
3241

@@ -98,13 +107,19 @@ export function ReviewSession() {
98107
</p>
99108
</div>
100109

110+
{submitError && (
111+
<div className="rounded-xl border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
112+
{submitError}
113+
</div>
114+
)}
115+
101116
<ReviewCard
102117
key={current.id}
103118
snippet={current}
104119
index={index}
105120
total={due.length}
106121
onRate={(rating) => handleRate(current, rating)}
107-
isSubmitting={submitReview.isPending}
122+
submittingRating={submittingRating}
108123
/>
109124
</div>
110125
)

apps/web/hooks/use-review.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client"
22

33
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
4+
import { useAuth } from "@clerk/nextjs"
45
import { createClient } from "@/lib/supabase/client"
56
import { snippetKeys } from "@/hooks/use-snippets"
67
import { useSupabaseUserId } from "@/hooks/use-user"
@@ -32,7 +33,9 @@ export function useReviewLogs() {
3233
}
3334

3435
export function useSubmitReview() {
35-
const { data: userId } = useSupabaseUserId()
36+
// clerkId must match the key used by useDueSnippets — they both use snippetKeys.due(clerkId)
37+
const { userId: clerkId } = useAuth()
38+
const { data: supabaseUserId } = useSupabaseUserId()
3639
const queryClient = useQueryClient()
3740

3841
return useMutation({
@@ -60,8 +63,10 @@ export function useSubmitReview() {
6063
}
6164
},
6265
onSuccess: () => {
63-
queryClient.invalidateQueries({ queryKey: snippetKeys.due(userId ?? "") })
64-
queryClient.invalidateQueries({ queryKey: reviewKeys.logs(userId ?? "") })
66+
queryClient.invalidateQueries({ queryKey: snippetKeys.due(clerkId ?? "") })
67+
if (supabaseUserId) {
68+
queryClient.invalidateQueries({ queryKey: reviewKeys.logs(supabaseUserId) })
69+
}
6570
},
6671
})
6772
}

0 commit comments

Comments
 (0)