Add deck generation and study flow improvements
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m22s

This commit is contained in:
Elijah 2026-07-13 14:56:33 -07:00
parent d6f3502cb1
commit 7b90409f2e
36 changed files with 8683 additions and 31 deletions

View 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
View 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) };
}

View 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 });
}

View 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
>;