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
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
448
src/services/spacedRepetitionService.ts
Normal file
448
src/services/spacedRepetitionService.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue