asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
11.4 kB
import type { SourceRef } from "./sessionData";
import type { MissionCatalog, MissionCatalogChapter, MissionSelection } from "./missionAdapter";
import {
getChapterProgress,
getMissionProgress,
isMissionComplete,
} from "./missionProgress";
import { getDueRevisionItems } from "./revisionScheduler";
/**
* Local Today Study Queue.
*
* Decides what DocDoe should suggest first when /tuition opens, using only the
* existing local data (revision scheduler, mission progress, chapter progress)
* plus the mission catalog from the metadata API. Pure — no React, no writes.
* source_refs are carried through unchanged when available.
*/
export type StudyQueueType = "revision" | "continue_mission" | "next_mission" | "chapter_complete";
export type StudyQueueItem = {
type: StudyQueueType;
priority: number;
title: string;
subtitle: string;
discipline: string;
chapterId: string;
chapterTitle: string;
chapterNo: number;
missionId: string;
missionTitle: string;
source_refs?: SourceRef[];
reason: string;
/** Selection to switch to (mission actions). */
selection?: MissionSelection;
/** Revision item id (revision actions). */
revisionItemId?: string;
};
export const STUDY_QUEUE_PRIORITY = {
overdue_revision: 100,
due_revision: 80,
continue_mission: 70,
next_mission: 50,
chapter_complete: 10,
} as const;
type MissionDescriptor = {
discipline: string;
chapterId: string;
chapterTitle: string;
chapterNo: number;
missionId: string;
missionTitle: string;
};
function missionShortTitle(title: string): string {
return title.replace(/^M\d+:\s*/i, "").trim() || title;
}
function startOfDay(now: number): number {
const date = new Date(now);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function chapterMetaById(
catalog: MissionCatalog | null,
chapterId: string,
): { discipline: string; chapterNo: number; chapterTitle: string } | null {
if (!catalog) return null;
for (const subject of catalog.subjects) {
const chapter = subject.chapters.find((item) => item.chapter_id === chapterId);
if (chapter) {
return { discipline: chapter.discipline || "", chapterNo: chapter.chapter_no, chapterTitle: chapter.chapter_title };
}
}
return null;
}
export function chapterForSelection(
catalog: MissionCatalog | null,
selection: MissionSelection,
): MissionCatalogChapter | null {
if (!catalog) return null;
const subject = catalog.subjects.find((s) => s.subject === selection.subject);
if (!subject) return null;
return (
subject.chapters.find(
(chapter) => chapter.chapter_id === selection.chapterId || (chapter.discipline === selection.discipline && chapter.chapter_no === selection.chapterNo),
) ?? null
);
}
function flatMissions(catalog: MissionCatalog | null): MissionDescriptor[] {
const out: MissionDescriptor[] = [];
for (const subject of catalog?.subjects ?? []) {
for (const chapter of subject.chapters) {
for (const mission of chapter.missions) {
out.push({
discipline: chapter.discipline || "",
chapterId: chapter.chapter_id,
chapterTitle: chapter.chapter_title,
chapterNo: chapter.chapter_no,
missionId: mission.mission_id,
missionTitle: mission.mission_title,
});
}
}
}
return out;
}
function toSelection(descriptor: MissionDescriptor, subjectName: "Social Science" | "Physics" = "Social Science"): MissionSelection {
return { board: "Kerala SSLC", classLevel: "10", subject: subjectName, discipline: descriptor.discipline, chapterNo: descriptor.chapterNo, chapterId: descriptor.chapterId, missionId: descriptor.missionId };
}
/** The mission the student currently has selected, as a descriptor. */
export function resolveCurrentMission(
catalog: MissionCatalog | null,
selection: MissionSelection,
): MissionDescriptor | null {
const chapter = chapterForSelection(catalog, selection);
if (!chapter) return null;
const mission = chapter.missions.find((item) => item.mission_id === selection.missionId);
return {
discipline: chapter.discipline || "",
chapterId: chapter.chapter_id,
chapterTitle: chapter.chapter_title,
chapterNo: chapter.chapter_no,
missionId: selection.missionId,
missionTitle: mission?.mission_title ?? selection.missionId,
};
}
/** The saved progress for the currently selected mission (or null). */
export function resolveCurrentMissionProgress(
studentId: string,
catalog: MissionCatalog | null,
selection: MissionSelection,
) {
const current = resolveCurrentMission(catalog, selection);
if (!current) return null;
return getMissionProgress(studentId, current.chapterId, current.missionId);
}
export type NextMissionResolution = MissionDescriptor & { unlocked: boolean; reason: string };
/**
* The next mission the student should do. If the current mission is in progress
* it returns the mission that comes after it ("up next"); otherwise it returns
* the first not-completed mission at or after the current one (the current
* mission itself when it is fresh, or the next chapter's first mission when the
* current chapter is complete).
*/
export function resolveNextUnlockedMission(
studentId: string,
catalog: MissionCatalog | null,
selection: MissionSelection,
): NextMissionResolution | null {
const flat = flatMissions(catalog);
if (flat.length === 0) return null;
const current = resolveCurrentMission(catalog, selection);
const currentIndex = current
? flat.findIndex((item) => item.chapterId === current.chapterId && item.missionId === current.missionId)
: -1;
const start = currentIndex >= 0 ? currentIndex : 0;
const currentProgress = current ? getMissionProgress(studentId, current.chapterId, current.missionId) : null;
const currentInProgress = Boolean(
currentProgress && currentProgress.missionStarted && !isMissionComplete(currentProgress),
);
const from = currentInProgress ? start + 1 : start;
for (let index = from; index < flat.length; index += 1) {
const descriptor = flat[index];
const chapterProgress = getChapterProgress(studentId, descriptor.chapterId);
if (chapterProgress.completedMissionIds.includes(descriptor.missionId)) continue;
const unlocked = descriptor.missionId === selection.missionId || chapterProgress.unlockedMissionIds.includes(descriptor.missionId) || isFirstMissionOfChapter(catalog, descriptor);
const sameChapter = current && descriptor.chapterId === current.chapterId;
const reason = currentInProgress
? "Up next after this mission"
: sameChapter
? "Your current mission is ready"
: "Next chapter unlocked";
return { ...descriptor, unlocked, reason };
}
return null;
}
function isFirstMissionOfChapter(catalog: MissionCatalog | null, descriptor: MissionDescriptor): boolean {
if (!catalog) return false;
for (const subject of catalog.subjects) {
const chapter = subject.chapters.find((item) => item.chapter_id === descriptor.chapterId);
if (chapter) {
return chapter.missions[0]?.mission_id === descriptor.missionId;
}
}
return false;
}
function isChapterComplete(studentId: string, chapter: MissionCatalogChapter): boolean {
if (chapter.missions.length === 0) return false;
const chapterProgress = getChapterProgress(studentId, chapter.chapter_id);
const completed = new Set(chapterProgress.completedMissionIds);
return chapter.missions.every((mission) => completed.has(mission.mission_id));
}
/** Build the ordered Today study queue. */
export function buildTodayStudyQueue(
studentId: string,
catalog: MissionCatalog | null,
selection: MissionSelection,
now: number = Date.now(),
): StudyQueueItem[] {
const items: StudyQueueItem[] = [];
const dayStart = startOfDay(now);
// 1. Due revision items (overdue first).
for (const revision of getDueRevisionItems(studentId, now)) {
const overdue = new Date(revision.dueAt).getTime() < dayStart;
const meta = chapterMetaById(catalog, revision.chapterId);
items.push({
type: "revision",
priority: overdue ? STUDY_QUEUE_PRIORITY.overdue_revision : STUDY_QUEUE_PRIORITY.due_revision,
title: `Revise: ${revision.weakAreaTag}`,
subtitle: revision.chapterTitle,
discipline: meta?.discipline ?? "",
chapterId: revision.chapterId,
chapterTitle: revision.chapterTitle,
chapterNo: meta?.chapterNo ?? 0,
missionId: revision.missionId,
missionTitle: "",
source_refs: revision.source_refs,
reason: overdue ? "Overdue revision" : "Revision due today",
revisionItemId: revision.id,
});
}
// 2. Unfinished current mission.
const current = resolveCurrentMission(catalog, selection);
if (current) {
const progress = getMissionProgress(studentId, current.chapterId, current.missionId);
if (progress && progress.missionStarted && !isMissionComplete(progress)) {
items.push({
type: "continue_mission",
priority: STUDY_QUEUE_PRIORITY.continue_mission,
title: `Continue: ${missionShortTitle(current.missionTitle)}`,
subtitle: current.chapterTitle,
discipline: current.discipline,
chapterId: current.chapterId,
chapterTitle: current.chapterTitle,
chapterNo: current.chapterNo,
missionId: current.missionId,
missionTitle: current.missionTitle,
source_refs: progress.source_refs,
reason: `Resume from the ${progress.lastStage} stage`,
selection: toSelection(current, selection.subject),
});
}
}
// 3. Next unlocked / up-next mission.
const next = resolveNextUnlockedMission(studentId, catalog, selection);
if (next) {
items.push({
type: "next_mission",
priority: STUDY_QUEUE_PRIORITY.next_mission,
title: `Start: ${missionShortTitle(next.missionTitle)}`,
subtitle: next.chapterTitle,
discipline: next.discipline,
chapterId: next.chapterId,
chapterTitle: next.chapterTitle,
chapterNo: next.chapterNo,
missionId: next.missionId,
missionTitle: next.missionTitle,
reason: next.reason,
selection: toSelection(next, selection.subject),
});
}
// 4. Completed chapter (info).
const currentChapter = chapterForSelection(catalog, selection);
if (currentChapter && isChapterComplete(studentId, currentChapter)) {
items.push({
type: "chapter_complete",
priority: STUDY_QUEUE_PRIORITY.chapter_complete,
title: `Chapter complete: ${currentChapter.chapter_title}`,
subtitle: `${currentChapter.missions.length}/${currentChapter.missions.length} missions`,
discipline: currentChapter.discipline || "",
chapterId: currentChapter.chapter_id,
chapterTitle: currentChapter.chapter_title,
chapterNo: currentChapter.chapter_no,
missionId: "",
missionTitle: "",
reason: "All missions in this chapter are completed",
});
}
return items.sort((a, b) => b.priority - a.priority);
}
/** The single highest-priority action (or null when nothing is queued). */
export function getTopStudyAction(
studentId: string,
catalog: MissionCatalog | null,
selection: MissionSelection,
now: number = Date.now(),
): StudyQueueItem | null {
return buildTodayStudyQueue(studentId, catalog, selection, now)[0] ?? null;
}