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