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

@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService";
function slugify(name: string): string {
return name
@ -8,13 +9,27 @@ function slugify(name: string): string {
}
export async function listClasses() {
return prisma.class.findMany({
orderBy: { sortOrder: "asc" },
include: {
_count: {
select: { decks: true, quizSets: true },
const [classes, studyAvailability] = await Promise.all([
prisma.class.findMany({
orderBy: { sortOrder: "asc" },
include: {
_count: {
select: { decks: true, quizSets: true },
},
},
},
}),
getStudyAvailabilityByClass(),
]);
return classes.map((classItem) => {
const availability = studyAvailability[classItem.id];
return {
...classItem,
spacedRepetitionDueCards: availability?.dueCards ?? 0,
spacedRepetitionLearningCards: availability?.learningCards ?? 0,
spacedRepetitionNewCards: availability?.newCards ?? 0,
spacedRepetitionReadyCards: availability?.readyCards ?? 0,
};
});
}

View file

@ -0,0 +1,448 @@
import { prisma } from "@/lib/db";
import {
calculateQueueCounts,
getArizonaDayBounds,
getEffectiveDue,
getLearnAheadCutoff,
INTRADAY_LEARNING_STATES,
previewRatings,
scheduleRating,
type SpacedRepetitionRating,
type StoredScheduleState,
} from "@/lib/spacedRepetition";
import type {
CreateSpacedRepetitionSetInput,
UpdateSpacedRepetitionSetInput,
} from "@/lib/validation/spacedRepetitionSchemas";
export class SpacedRepetitionServiceError extends Error {
constructor(message: string, public status: 400 | 404 | 409) {
super(message);
}
}
const cardStateSelect = {
id: true,
due: true,
stability: true,
difficulty: true,
elapsedDays: true,
scheduledDays: true,
learningSteps: true,
reps: true,
lapses: true,
state: true,
lastReview: true,
updatedAt: true,
} as const;
function asStoredState(state: {
due: Date;
stability: number;
difficulty: number;
elapsedDays: number;
scheduledDays: number;
learningSteps: number;
reps: number;
lapses: number;
state: number;
lastReview: Date | null;
}): StoredScheduleState {
return state;
}
async function getSetSummary(setId: string, now: Date) {
const { start, end } = getArizonaDayBounds(now);
const set = await prisma.spacedRepetitionSet.findUnique({
where: { id: setId },
include: {
class: { select: { id: true, slug: true, name: true } },
decks: {
orderBy: { sortOrder: "asc" },
include: {
deck: {
select: {
id: true,
name: true,
description: true,
_count: { select: { cards: true } },
},
},
},
},
cardStates: {
select: {
due: true,
firstReviewedAt: true,
scheduledDays: true,
state: true,
},
},
},
});
if (!set) throw new SpacedRepetitionServiceError("Repetition set not found", 404);
const totalCards = set.decks.reduce(
(sum, membership) => sum + membership.deck._count.cards,
0
);
const reviewedCards = set.cardStates.length;
const dueCards = set.cardStates.filter(
(state) => getEffectiveDue(state) <= now
).length;
const learnAheadCutoff = getLearnAheadCutoff(now);
const learningAheadCards = set.cardStates.filter(
(state) =>
getEffectiveDue(state) > now &&
getEffectiveDue(state) <= learnAheadCutoff &&
INTRADAY_LEARNING_STATES.includes(state.state)
).length;
const introducedToday = set.cardStates.filter(
(state) => state.firstReviewedAt >= start && state.firstReviewedAt < end
).length;
const { unseenCards, newCardsAvailable, availableCards } = calculateQueueCounts({
totalCards,
reviewedCards,
dueCards,
learningAheadCards,
introducedToday,
newCardsPerDay: set.newCardsPerDay,
});
const nextDue = set.cardStates
.map((state) => getEffectiveDue(state))
.filter((due) => due > now)
.sort((a, b) => a.getTime() - b.getTime())[0];
return {
id: set.id,
classId: set.classId,
name: set.name,
description: set.description,
newCardsPerDay: set.newCardsPerDay,
sortOrder: set.sortOrder,
createdAt: set.createdAt,
updatedAt: set.updatedAt,
class: set.class,
decks: set.decks.map((membership) => ({
...membership.deck,
sortOrder: membership.sortOrder,
})),
stats: {
totalCards,
reviewedCards,
unseenCards,
dueCards,
learningAheadCards,
newCardsAvailable,
availableCards,
nextDue: nextDue?.toISOString() ?? null,
},
};
}
export async function listSetsByClass(classId: string, now = new Date()) {
const ids = await prisma.spacedRepetitionSet.findMany({
where: { classId },
orderBy: { sortOrder: "asc" },
select: { id: true },
});
return Promise.all(ids.map(({ id }) => getSetSummary(id, now)));
}
export async function getStudyAvailabilityByClass(now = new Date()) {
const { start, end } = getArizonaDayBounds(now);
const learnAheadCutoff = getLearnAheadCutoff(now);
const sets = await prisma.spacedRepetitionSet.findMany({
select: {
classId: true,
newCardsPerDay: true,
decks: {
select: {
deck: { select: { _count: { select: { cards: true } } } },
},
},
cardStates: {
select: {
due: true,
firstReviewedAt: true,
scheduledDays: true,
state: true,
},
},
},
});
return sets.reduce<Record<string, {
dueCards: number;
learningCards: number;
newCards: number;
readyCards: number;
}>>((availability, set) => {
const totalCards = set.decks.reduce(
(sum, membership) => sum + membership.deck._count.cards,
0
);
const dueCards = set.cardStates.filter(
(state) => getEffectiveDue(state) <= now
).length;
const learningCards = set.cardStates.filter((state) => {
const effectiveDue = getEffectiveDue(state);
return effectiveDue > now &&
effectiveDue <= learnAheadCutoff &&
INTRADAY_LEARNING_STATES.includes(state.state);
}).length;
const introducedToday = set.cardStates.filter(
(state) => state.firstReviewedAt >= start && state.firstReviewedAt < end
).length;
const counts = calculateQueueCounts({
totalCards,
reviewedCards: set.cardStates.length,
dueCards,
learningAheadCards: learningCards,
introducedToday,
newCardsPerDay: set.newCardsPerDay,
});
const current = availability[set.classId] ?? {
dueCards: 0,
learningCards: 0,
newCards: 0,
readyCards: 0,
};
availability[set.classId] = {
dueCards: current.dueCards + dueCards,
learningCards: current.learningCards + learningCards,
newCards: current.newCards + counts.newCardsAvailable,
readyCards: current.readyCards + counts.availableCards,
};
return availability;
}, {});
}
export async function createSet(data: CreateSpacedRepetitionSetInput) {
const classExists = await prisma.class.findUnique({
where: { id: data.classId },
select: { id: true },
});
if (!classExists) throw new SpacedRepetitionServiceError("Class not found", 404);
const maximum = await prisma.spacedRepetitionSet.aggregate({
where: { classId: data.classId },
_max: { sortOrder: true },
});
return prisma.spacedRepetitionSet.create({
data: {
classId: data.classId,
name: data.name,
description: data.description || null,
newCardsPerDay: data.newCardsPerDay,
sortOrder: (maximum._max.sortOrder ?? -1) + 1,
},
});
}
export async function getSet(id: string, now = new Date()) {
return getSetSummary(id, now);
}
export async function updateSet(id: string, data: UpdateSpacedRepetitionSetInput) {
const exists = await prisma.spacedRepetitionSet.findUnique({ where: { id } });
if (!exists) throw new SpacedRepetitionServiceError("Repetition set not found", 404);
return prisma.spacedRepetitionSet.update({
where: { id },
data: {
name: data.name,
description: data.description === undefined ? undefined : data.description || null,
newCardsPerDay: data.newCardsPerDay,
},
});
}
export async function deleteSet(id: string) {
const result = await prisma.spacedRepetitionSet.deleteMany({ where: { id } });
if (!result.count) throw new SpacedRepetitionServiceError("Repetition set not found", 404);
}
export async function addDeck(setId: string, deckId: string) {
const [set, deck, existing] = await Promise.all([
prisma.spacedRepetitionSet.findUnique({ where: { id: setId } }),
prisma.deck.findUnique({ where: { id: deckId } }),
prisma.spacedRepetitionSetDeck.findUnique({
where: { setId_deckId: { setId, deckId } },
}),
]);
if (!set) throw new SpacedRepetitionServiceError("Repetition set not found", 404);
if (!deck) throw new SpacedRepetitionServiceError("Deck not found", 404);
if (set.classId !== deck.classId) {
throw new SpacedRepetitionServiceError("Deck belongs to another class", 400);
}
if (existing) throw new SpacedRepetitionServiceError("Deck is already in this set", 409);
const maximum = await prisma.spacedRepetitionSetDeck.aggregate({
where: { setId },
_max: { sortOrder: true },
});
return prisma.spacedRepetitionSetDeck.create({
data: { setId, deckId, sortOrder: (maximum._max.sortOrder ?? -1) + 1 },
});
}
export async function removeDeck(setId: string, deckId: string) {
const membership = await prisma.spacedRepetitionSetDeck.findUnique({
where: { setId_deckId: { setId, deckId } },
});
if (!membership) throw new SpacedRepetitionServiceError("Deck membership not found", 404);
await prisma.$transaction([
prisma.spacedRepetitionCardState.deleteMany({
where: { setId, flashcard: { deckId } },
}),
prisma.spacedRepetitionSetDeck.delete({
where: { setId_deckId: { setId, deckId } },
}),
]);
}
export async function reorderDecks(setId: string, deckIds: string[]) {
if (new Set(deckIds).size !== deckIds.length) {
throw new SpacedRepetitionServiceError("Deck order contains duplicates", 400);
}
const memberships = await prisma.spacedRepetitionSetDeck.findMany({
where: { setId },
select: { deckId: true },
});
const existing = new Set(memberships.map((item) => item.deckId));
if (existing.size !== deckIds.length || deckIds.some((id) => !existing.has(id))) {
throw new SpacedRepetitionServiceError("Deck order must include every set deck", 400);
}
await prisma.$transaction(
deckIds.map((deckId, sortOrder) =>
prisma.spacedRepetitionSetDeck.update({
where: { setId_deckId: { setId, deckId } },
data: { sortOrder },
})
)
);
}
async function findNextCard(setId: string, now: Date, allowNew: boolean) {
const { end } = getArizonaDayBounds(now);
const due = await prisma.spacedRepetitionCardState.findFirst({
where: {
setId,
OR: [
{ scheduledDays: 0, due: { lte: now } },
{ scheduledDays: { gte: 1 }, due: { lt: end } },
],
flashcard: { deck: { spacedRepetitionMemberships: { some: { setId } } } },
},
orderBy: { due: "asc" },
include: { flashcard: { include: { deck: { select: { name: true } } } } },
});
if (due) {
return {
flashcard: due.flashcard,
updatedAt: due.updatedAt,
state: asStoredState(due),
};
}
if (allowNew) {
const memberships = await prisma.spacedRepetitionSetDeck.findMany({
where: { setId },
orderBy: { sortOrder: "asc" },
select: { deckId: true },
});
for (const membership of memberships) {
const card = await prisma.flashcard.findFirst({
where: {
deckId: membership.deckId,
spacedRepetitionStates: { none: { setId } },
},
orderBy: { sortOrder: "asc" },
include: { deck: { select: { name: true } } },
});
if (card) return { flashcard: card, updatedAt: null, state: null };
}
}
const learningAhead = await prisma.spacedRepetitionCardState.findFirst({
where: {
setId,
state: { in: INTRADAY_LEARNING_STATES },
due: { gt: now, lte: getLearnAheadCutoff(now) },
flashcard: { deck: { spacedRepetitionMemberships: { some: { setId } } } },
},
orderBy: { due: "asc" },
include: { flashcard: { include: { deck: { select: { name: true } } } } },
});
if (learningAhead) {
return {
flashcard: learningAhead.flashcard,
updatedAt: learningAhead.updatedAt,
state: asStoredState(learningAhead),
};
}
return null;
}
export async function getStudyState(setId: string, now = new Date()) {
const summary = await getSetSummary(setId, now);
const next = await findNextCard(setId, now, summary.stats.newCardsAvailable > 0);
return {
set: summary,
currentCard: next
? {
id: next.flashcard.id,
front: next.flashcard.front,
back: next.flashcard.back,
sourceDeckName: next.flashcard.deck.name,
stateVersion: next.updatedAt?.toISOString() ?? null,
previews: previewRatings(next.state, now),
}
: null,
};
}
export async function reviewCard(data: {
setId: string;
flashcardId: string;
rating: SpacedRepetitionRating;
expectedStateVersion: string | null;
now?: Date;
}) {
const now = data.now ?? new Date();
const card = await prisma.flashcard.findFirst({
where: {
id: data.flashcardId,
deck: { spacedRepetitionMemberships: { some: { setId: data.setId } } },
},
});
if (!card) throw new SpacedRepetitionServiceError("Card is not in this repetition set", 404);
const current = await prisma.spacedRepetitionCardState.findUnique({
where: { setId_flashcardId: { setId: data.setId, flashcardId: data.flashcardId } },
select: { ...cardStateSelect, firstReviewedAt: true },
});
const currentVersion = current?.updatedAt.toISOString() ?? null;
if (currentVersion !== data.expectedStateVersion) {
throw new SpacedRepetitionServiceError("This card was already reviewed. Refreshing the queue is required.", 409);
}
const next = scheduleRating(current ? asStoredState(current) : null, data.rating, now);
await prisma.$transaction(async (tx) => {
if (current) {
const updated = await tx.spacedRepetitionCardState.updateMany({
where: { id: current.id, updatedAt: current.updatedAt },
data: next,
});
if (!updated.count) throw new SpacedRepetitionServiceError("Card state changed", 409);
} else {
await tx.spacedRepetitionCardState.create({
data: { setId: data.setId, flashcardId: data.flashcardId, ...next, firstReviewedAt: now },
});
}
await tx.studyActivity.create({ data: { type: "FLASHCARD", occurredAt: now } });
});
return getStudyState(data.setId, now);
}