Add deck generation and study flow improvements
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m22s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m22s
This commit is contained in:
parent
d6f3502cb1
commit
7b90409f2e
36 changed files with 8683 additions and 31 deletions
121
src/lib/spacedRepetition.test.ts
Normal file
121
src/lib/spacedRepetition.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateQueueCounts,
|
||||
getArizonaDayBounds,
|
||||
getEffectiveDue,
|
||||
getLearnAheadCutoff,
|
||||
previewRatings,
|
||||
scheduleRating,
|
||||
type SpacedRepetitionRating,
|
||||
} from "@/lib/spacedRepetition";
|
||||
|
||||
describe("spaced repetition scheduling", () => {
|
||||
const now = new Date("2026-07-13T20:00:00.000Z");
|
||||
|
||||
it("returns a distinct valid state for every rating", () => {
|
||||
const ratings: SpacedRepetitionRating[] = ["AGAIN", "HARD", "GOOD", "EASY"];
|
||||
for (const rating of ratings) {
|
||||
const state = scheduleRating(null, rating, now);
|
||||
expect(state.reps).toBe(1);
|
||||
expect(state.lastReview?.toISOString()).toBe(now.toISOString());
|
||||
expect(state.due.getTime()).toBeGreaterThan(now.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it("orders first-review previews from Again through Easy", () => {
|
||||
const previews = previewRatings(null, now);
|
||||
const dueTimes = previews.map((preview) => new Date(preview.due).getTime());
|
||||
expect(dueTimes).toHaveLength(4);
|
||||
expect(dueTimes[0]).toBeLessThan(dueTimes[1]);
|
||||
expect(dueTimes[1]).toBeLessThanOrEqual(dueTimes[2]);
|
||||
expect(dueTimes[2]).toBeLessThan(dueTimes[3]);
|
||||
expect(dueTimes.map((due) => Math.round((due - now.getTime()) / 60000))).toEqual([
|
||||
1,
|
||||
6,
|
||||
10,
|
||||
7 * 24 * 60 + 11 * 60,
|
||||
]);
|
||||
expect(new Date(previews[3].due).toISOString()).toBe(
|
||||
"2026-07-21T07:00:00.000Z"
|
||||
);
|
||||
expect(previews[3].scheduledDays).toBe(8);
|
||||
});
|
||||
|
||||
it("aligns graduated day intervals to Arizona midnight", () => {
|
||||
const learning = scheduleRating(null, "GOOD", now);
|
||||
const graduated = scheduleRating(learning, "GOOD", learning.due);
|
||||
|
||||
expect(graduated.scheduledDays).toBeGreaterThanOrEqual(1);
|
||||
expect(graduated.due.toISOString()).toMatch(/T07:00:00\.000Z$/);
|
||||
});
|
||||
|
||||
it("uses Anki's 20-minute learn-ahead window", () => {
|
||||
expect(getLearnAheadCutoff(now).toISOString()).toBe("2026-07-13T20:20:00.000Z");
|
||||
});
|
||||
|
||||
it("uses midnight in America/Phoenix as the daily boundary", () => {
|
||||
const beforeMidnight = getArizonaDayBounds(new Date("2026-07-14T06:59:59.000Z"));
|
||||
const afterMidnight = getArizonaDayBounds(new Date("2026-07-14T07:00:00.000Z"));
|
||||
expect(beforeMidnight.start.toISOString()).toBe("2026-07-13T07:00:00.000Z");
|
||||
expect(afterMidnight.start.toISOString()).toBe("2026-07-14T07:00:00.000Z");
|
||||
});
|
||||
|
||||
it("treats legacy day-based due times as due at midnight", () => {
|
||||
expect(getEffectiveDue({
|
||||
due: new Date("2026-07-14T20:00:00.000Z"),
|
||||
scheduledDays: 1,
|
||||
}).toISOString()).toBe("2026-07-14T07:00:00.000Z");
|
||||
expect(getEffectiveDue({
|
||||
due: new Date("2026-07-14T07:10:00.000Z"),
|
||||
scheduledDays: 0,
|
||||
}).toISOString()).toBe("2026-07-14T07:10:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daily queue counts", () => {
|
||||
it("adds all due cards while capping only new cards", () => {
|
||||
expect(calculateQueueCounts({
|
||||
totalCards: 100,
|
||||
reviewedCards: 40,
|
||||
dueCards: 55,
|
||||
introducedToday: 12,
|
||||
newCardsPerDay: 30,
|
||||
})).toEqual({ unseenCards: 60, newCardsAvailable: 18, learningAheadCards: 0, availableCards: 73 });
|
||||
});
|
||||
|
||||
it("can pause new cards without hiding due reviews", () => {
|
||||
expect(calculateQueueCounts({
|
||||
totalCards: 100,
|
||||
reviewedCards: 40,
|
||||
dueCards: 7,
|
||||
introducedToday: 0,
|
||||
newCardsPerDay: 0,
|
||||
})).toEqual({ unseenCards: 60, newCardsAvailable: 0, learningAheadCards: 0, availableCards: 7 });
|
||||
});
|
||||
|
||||
it("never offers more new cards than remain unseen", () => {
|
||||
expect(calculateQueueCounts({
|
||||
totalCards: 12,
|
||||
reviewedCards: 10,
|
||||
dueCards: 0,
|
||||
introducedToday: 0,
|
||||
newCardsPerDay: 30,
|
||||
}).newCardsAvailable).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps short learning steps available when no new cards remain", () => {
|
||||
expect(calculateQueueCounts({
|
||||
totalCards: 25,
|
||||
reviewedCards: 25,
|
||||
dueCards: 0,
|
||||
learningAheadCards: 25,
|
||||
introducedToday: 25,
|
||||
newCardsPerDay: 30,
|
||||
})).toEqual({
|
||||
unseenCards: 0,
|
||||
newCardsAvailable: 0,
|
||||
learningAheadCards: 25,
|
||||
availableCards: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
162
src/lib/spacedRepetition.ts
Normal file
162
src/lib/spacedRepetition.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import {
|
||||
createEmptyCard,
|
||||
fsrs,
|
||||
Rating,
|
||||
State,
|
||||
type Card,
|
||||
type Grade,
|
||||
} from "ts-fsrs";
|
||||
|
||||
export const SPACED_REPETITION_RATINGS = [
|
||||
"AGAIN",
|
||||
"HARD",
|
||||
"GOOD",
|
||||
"EASY",
|
||||
] as const;
|
||||
|
||||
export type SpacedRepetitionRating =
|
||||
(typeof SPACED_REPETITION_RATINGS)[number];
|
||||
|
||||
export interface StoredScheduleState {
|
||||
due: Date;
|
||||
stability: number;
|
||||
difficulty: number;
|
||||
elapsedDays: number;
|
||||
scheduledDays: number;
|
||||
learningSteps: number;
|
||||
reps: number;
|
||||
lapses: number;
|
||||
state: number;
|
||||
lastReview: Date | null;
|
||||
}
|
||||
|
||||
export interface RatingPreview {
|
||||
rating: SpacedRepetitionRating;
|
||||
due: string;
|
||||
scheduledDays: number;
|
||||
}
|
||||
|
||||
export const LEARN_AHEAD_LIMIT_MINUTES = 20;
|
||||
export const INTRADAY_LEARNING_STATES: number[] = [
|
||||
State.Learning,
|
||||
State.Relearning,
|
||||
];
|
||||
|
||||
export function getLearnAheadCutoff(now: Date) {
|
||||
return new Date(now.getTime() + LEARN_AHEAD_LIMIT_MINUTES * 60 * 1000);
|
||||
}
|
||||
|
||||
export function getEffectiveDue(state: { due: Date; scheduledDays: number }) {
|
||||
return state.scheduledDays >= 1
|
||||
? getArizonaDayBounds(state.due).start
|
||||
: state.due;
|
||||
}
|
||||
|
||||
export function calculateQueueCounts(input: {
|
||||
totalCards: number;
|
||||
reviewedCards: number;
|
||||
dueCards: number;
|
||||
learningAheadCards?: number;
|
||||
introducedToday: number;
|
||||
newCardsPerDay: number;
|
||||
}) {
|
||||
const unseenCards = Math.max(0, input.totalCards - input.reviewedCards);
|
||||
const newCardsAvailable = Math.min(
|
||||
unseenCards,
|
||||
Math.max(0, input.newCardsPerDay - input.introducedToday)
|
||||
);
|
||||
return {
|
||||
unseenCards,
|
||||
newCardsAvailable,
|
||||
learningAheadCards: input.learningAheadCards ?? 0,
|
||||
availableCards:
|
||||
input.dueCards + newCardsAvailable + (input.learningAheadCards ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
const scheduler = fsrs({ request_retention: 0.9, enable_fuzz: false });
|
||||
|
||||
const ratingMap: Record<SpacedRepetitionRating, Grade> = {
|
||||
AGAIN: Rating.Again,
|
||||
HARD: Rating.Hard,
|
||||
GOOD: Rating.Good,
|
||||
EASY: Rating.Easy,
|
||||
};
|
||||
|
||||
function toCard(state: StoredScheduleState | null, now: Date): Card {
|
||||
if (!state) return createEmptyCard(now);
|
||||
|
||||
return {
|
||||
due: state.due,
|
||||
stability: state.stability,
|
||||
difficulty: state.difficulty,
|
||||
elapsed_days: state.elapsedDays,
|
||||
scheduled_days: state.scheduledDays,
|
||||
learning_steps: state.learningSteps,
|
||||
reps: state.reps,
|
||||
lapses: state.lapses,
|
||||
state: state.state as State,
|
||||
last_review: state.lastReview ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function fromCard(card: Card): StoredScheduleState {
|
||||
return {
|
||||
due: card.due,
|
||||
stability: card.stability,
|
||||
difficulty: card.difficulty,
|
||||
elapsedDays: card.elapsed_days,
|
||||
scheduledDays: card.scheduled_days,
|
||||
learningSteps: card.learning_steps,
|
||||
reps: card.reps,
|
||||
lapses: card.lapses,
|
||||
state: card.state,
|
||||
lastReview: card.last_review ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function alignDayIntervalToArizonaMidnight(card: Card, now: Date): Card {
|
||||
if (card.scheduled_days < 1) return card;
|
||||
|
||||
const { start } = getArizonaDayBounds(now);
|
||||
return {
|
||||
...card,
|
||||
due: new Date(
|
||||
start.getTime() + card.scheduled_days * 24 * 60 * 60 * 1000
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function previewRatings(
|
||||
state: StoredScheduleState | null,
|
||||
now: Date
|
||||
): RatingPreview[] {
|
||||
const preview = scheduler.repeat(toCard(state, now), now);
|
||||
return SPACED_REPETITION_RATINGS.map((rating) => {
|
||||
const card = alignDayIntervalToArizonaMidnight(
|
||||
preview[ratingMap[rating]].card,
|
||||
now
|
||||
);
|
||||
return {
|
||||
rating,
|
||||
due: card.due.toISOString(),
|
||||
scheduledDays: card.scheduled_days,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function scheduleRating(
|
||||
state: StoredScheduleState | null,
|
||||
rating: SpacedRepetitionRating,
|
||||
now: Date
|
||||
): StoredScheduleState {
|
||||
const next = scheduler.next(toCard(state, now), now, ratingMap[rating]).card;
|
||||
return fromCard(alignDayIntervalToArizonaMidnight(next, now));
|
||||
}
|
||||
|
||||
export function getArizonaDayBounds(now: Date): { start: Date; end: Date } {
|
||||
const arizonaNow = new Date(now.getTime() - 7 * 60 * 60 * 1000);
|
||||
const dateKey = arizonaNow.toISOString().slice(0, 10);
|
||||
const start = new Date(`${dateKey}T07:00:00.000Z`);
|
||||
return { start, end: new Date(start.getTime() + 24 * 60 * 60 * 1000) };
|
||||
}
|
||||
9
src/lib/spacedRepetitionApi.ts
Normal file
9
src/lib/spacedRepetitionApi.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { SpacedRepetitionServiceError } from "@/services/spacedRepetitionService";
|
||||
|
||||
export function spacedRepetitionErrorResponse(error: unknown) {
|
||||
if (error instanceof SpacedRepetitionServiceError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
return NextResponse.json({ error: "Spaced repetition request failed" }, { status: 500 });
|
||||
}
|
||||
44
src/lib/validation/spacedRepetitionSchemas.ts
Normal file
44
src/lib/validation/spacedRepetitionSchemas.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { z } from "zod";
|
||||
import { SPACED_REPETITION_RATINGS } from "@/lib/spacedRepetition";
|
||||
|
||||
const nameSchema = z.string().trim().min(1).max(120);
|
||||
const descriptionSchema = z.string().trim().max(5000).nullable().optional();
|
||||
const newCardsPerDaySchema = z.number().int().min(0).max(999);
|
||||
|
||||
export const createSpacedRepetitionSetSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
name: nameSchema,
|
||||
description: descriptionSchema,
|
||||
newCardsPerDay: newCardsPerDaySchema.default(30),
|
||||
});
|
||||
|
||||
export const updateSpacedRepetitionSetSchema = z
|
||||
.object({
|
||||
name: nameSchema.optional(),
|
||||
description: descriptionSchema,
|
||||
newCardsPerDay: newCardsPerDaySchema.optional(),
|
||||
})
|
||||
.refine((value) => Object.values(value).some((item) => item !== undefined), {
|
||||
message: "At least one field is required",
|
||||
});
|
||||
|
||||
export const addSpacedRepetitionDeckSchema = z.object({
|
||||
deckId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const reorderSpacedRepetitionDecksSchema = z.object({
|
||||
deckIds: z.array(z.string().min(1)).max(1000),
|
||||
});
|
||||
|
||||
export const reviewSpacedRepetitionCardSchema = z.object({
|
||||
flashcardId: z.string().min(1),
|
||||
rating: z.enum(SPACED_REPETITION_RATINGS),
|
||||
expectedStateVersion: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export type CreateSpacedRepetitionSetInput = z.infer<
|
||||
typeof createSpacedRepetitionSetSchema
|
||||
>;
|
||||
export type UpdateSpacedRepetitionSetInput = z.infer<
|
||||
typeof updateSpacedRepetitionSetSchema
|
||||
>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue