Skip to content
Merged
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
5 changes: 4 additions & 1 deletion apps/api/app/algorithms/sm2.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ def sm2_schedule(
new_repetitions = repetitions + 1

if repetitions == 0:
new_interval = 1
# Quality-adjusted first interval: Easy gets 4 days, Hard gets 2 days.
# Standard SM-2 fixes this at 1, but that makes every first review
# feel identical regardless of confidence — poor UX.
new_interval = 4 if quality >= 4 else 2
elif repetitions == 1:
new_interval = 6
else:
Expand Down
15 changes: 11 additions & 4 deletions apps/api/tests/test_sm2.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@
# Basic scheduling progression
# ---------------------------------------------------------------------------

def test_first_review_easy_interval_is_one():
"""First review (repetitions=0) always produces interval=1 regardless of quality."""
def test_first_review_easy_gives_four_day_interval():
"""First review (repetitions=0) with quality>=4 (Easy) gives interval=4."""
interval, reps, ef = sm2_schedule(quality=5, repetitions=0, ease_factor=2.5, interval=1)
assert interval == 1
assert interval == 4
assert reps == 1


def test_first_review_hard_gives_two_day_interval():
"""First review (repetitions=0) with quality==3 (Hard) gives interval=2."""
interval, reps, ef = sm2_schedule(quality=3, repetitions=0, ease_factor=2.5, interval=1)
assert interval == 2
assert reps == 1


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

# After the fixed steps (1 and 6), each interval must grow
# After the quality-adjusted first step (4) and the fixed second step (6), intervals grow
if step >= 2:
assert interval >= prev_interval, (
f"Interval did not grow at step {step}: {interval} < {prev_interval}"
Expand Down
33 changes: 28 additions & 5 deletions apps/web/components/review-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ interface ReviewCardProps {
index: number
total: number
onRate: (rating: 1 | 3 | 5) => void
isSubmitting: boolean
submittingRating: 1 | 3 | 5 | null
}

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

return (
Expand Down Expand Up @@ -120,20 +121,23 @@ export function ReviewCard({ snippet, index, total, onRate, isSubmitting }: Revi
sublabel="Start over"
onClick={() => onRate(1)}
disabled={isSubmitting}
isActive={submittingRating === 1}
variant="forgot"
/>
<RatingButton
label="Hard"
sublabel="Got it, barely"
onClick={() => onRate(3)}
disabled={isSubmitting}
isActive={submittingRating === 3}
variant="hard"
/>
<RatingButton
label="Easy"
sublabel="Got it!"
onClick={() => onRate(5)}
disabled={isSubmitting}
isActive={submittingRating === 5}
variant="easy"
/>
</div>
Expand All @@ -148,12 +152,14 @@ function RatingButton({
sublabel,
onClick,
disabled,
isActive,
variant,
}: {
label: string
sublabel: string
onClick: () => void
disabled: boolean
isActive: boolean
variant: "forgot" | "hard" | "easy"
}) {
const styles = {
Expand All @@ -170,12 +176,29 @@ function RatingButton({
className={cn(
"flex flex-col items-center gap-0.5 rounded-xl border px-3 py-3.5",
"active:scale-[0.96] transition-[transform,background-color,opacity] duration-100",
"disabled:pointer-events-none disabled:opacity-50",
"disabled:pointer-events-none",
isActive ? "opacity-100" : "disabled:opacity-40",
styles[variant],
)}
>
<span className="text-sm font-semibold">{label}</span>
<span className="text-[11px] opacity-70">{sublabel}</span>
{isActive ? (
<svg
className="size-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-label="Processing"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2.5" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<span className="text-sm font-semibold">{label}</span>
)}
<span className="text-[11px] opacity-70">{isActive ? "Saving…" : sublabel}</span>
</button>
)
}
41 changes: 28 additions & 13 deletions apps/web/components/review-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,29 @@ export function ReviewSession() {

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

const handleRate = async (snippet: Snippet, rating: 1 | 3 | 5) => {
await submitReview.mutateAsync({
snippetId: snippet.id,
rating,
currentEase: snippet.ease_factor,
currentInterval: snippet.interval_days,
currentReps: snippet.repetitions,
})

if (index + 1 >= due.length) {
setDone(true)
} else {
setIndex((i) => i + 1)
setSubmittingRating(rating)
setSubmitError(null)
try {
await submitReview.mutateAsync({
snippetId: snippet.id,
rating,
currentEase: snippet.ease_factor,
currentInterval: snippet.interval_days,
currentReps: snippet.repetitions,
})
if (index + 1 >= due.length) {
setDone(true)
} else {
setIndex((i) => i + 1)
}
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Something went wrong. Try again.")
} finally {
setSubmittingRating(null)
}
}

Expand Down Expand Up @@ -98,13 +107,19 @@ export function ReviewSession() {
</p>
</div>

{submitError && (
<div className="rounded-xl border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
{submitError}
</div>
)}

<ReviewCard
key={current.id}
snippet={current}
index={index}
total={due.length}
onRate={(rating) => handleRate(current, rating)}
isSubmitting={submitReview.isPending}
submittingRating={submittingRating}
/>
</div>
)
Expand Down
11 changes: 8 additions & 3 deletions apps/web/hooks/use-review.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

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

export function useSubmitReview() {
const { data: userId } = useSupabaseUserId()
// clerkId must match the key used by useDueSnippets — they both use snippetKeys.due(clerkId)
const { userId: clerkId } = useAuth()
const { data: supabaseUserId } = useSupabaseUserId()
const queryClient = useQueryClient()

return useMutation({
Expand Down Expand Up @@ -60,8 +63,10 @@ export function useSubmitReview() {
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: snippetKeys.due(userId ?? "") })
queryClient.invalidateQueries({ queryKey: reviewKeys.logs(userId ?? "") })
queryClient.invalidateQueries({ queryKey: snippetKeys.due(clerkId ?? "") })
if (supabaseUserId) {
queryClient.invalidateQueries({ queryKey: reviewKeys.logs(supabaseUserId) })
}
},
})
}
Loading