import { db } from '../../db/client.js'; import { categories, skillTree, userChapterProgress } from '../../db/schema.js'; import { asc, eq, sql } from 'drizzle-orm'; import type { NodeStatus, ThemeNodeDto, ThemeTrackDto } from '../../types/app-api.js'; import { BASE_XP } from '../progress/xp-service.js'; const DEFAULT_TRACK_ICON = '📚'; const TRACK_ICONS: Readonly> = Object.freeze({ history: '🏛', drama: '🎭', crosstalk: '🎙', geography: '🗺', general: '💡', }); type CategoryRow = typeof categories.$inferSelect; type ChapterRow = typeof skillTree.$inferSelect; type ChapterProgressRow = typeof userChapterProgress.$inferSelect; function getIcon(category: CategoryRow): string { return TRACK_ICONS[category.slug] ?? TRACK_ICONS[category.id] ?? DEFAULT_TRACK_ICON; } function mapNodeStatus(status: ChapterProgressRow['status'] | undefined, hasAnyCurrent: boolean): NodeStatus { if (status === 'passed' || status === 'perfect') return 'done'; if (status === 'unlocked') return 'current'; if (!status && !hasAnyCurrent) return 'current'; return 'locked'; } function toNode(chapter: ChapterRow, progress: ChapterProgressRow | undefined, hasAnyCurrent: boolean): ThemeNodeDto { const questionCount = chapter.questionsRequired ?? 0; return { id: chapter.id, title: chapter.title, status: mapNodeStatus(progress?.status, hasAnyCurrent), reward: `+${BASE_XP * questionCount} XP`, questionCount, }; } function calculateTrackProgress(nodes: readonly ThemeNodeDto[]): number { if (nodes.length === 0) return 0; const done = nodes.filter((node) => node.status === 'done').length; return Math.round((done / nodes.length) * 100); } async function getProgressMap(userId: string): Promise> { const progress = await db .select() .from(userChapterProgress) .where(eq(userChapterProgress.userId, userId)); return new Map(progress.map((item) => [item.chapterId, item])); } export async function getThemeTracks(userId: string): Promise { const [activeCategories, chapters, progressMap] = await Promise.all([ db.select().from(categories).where(eq(categories.status, 'active')).orderBy(asc(categories.sortOrder)), db.select().from(skillTree).orderBy(asc(skillTree.sortOrder)), getProgressMap(userId), ]); return activeCategories.map((category) => { const categoryChapters = chapters.filter((chapter) => chapter.categoryId === category.id); const hasAnyCurrent = categoryChapters.some((chapter) => progressMap.get(chapter.id)?.status === 'unlocked'); const nodes = categoryChapters.map((chapter) => toNode(chapter, progressMap.get(chapter.id), hasAnyCurrent)); return { id: category.slug || category.id, name: category.name, icon: getIcon(category), progress: calculateTrackProgress(nodes), nodes, }; }); } export async function getThemeTrackById(userId: string, trackId: string): Promise { const [category] = await db .select() .from(categories) .where(sql`${categories.id} = ${trackId} OR ${categories.slug} = ${trackId}`) .limit(1); if (!category || category.status !== 'active') return null; const [chapters, progressMap] = await Promise.all([ db .select() .from(skillTree) .where(eq(skillTree.categoryId, category.id)) .orderBy(asc(skillTree.sortOrder)), getProgressMap(userId), ]); const hasAnyCurrent = chapters.some((chapter) => progressMap.get(chapter.id)?.status === 'unlocked'); const nodes = chapters.map((chapter) => toNode(chapter, progressMap.get(chapter.id), hasAnyCurrent)); return { id: category.slug || category.id, name: category.name, icon: getIcon(category), progress: calculateTrackProgress(nodes), nodes, }; } export async function getTrackCategory(trackId: string): Promise { const [category] = await db .select() .from(categories) .where(sql`${categories.id} = ${trackId} OR ${categories.slug} = ${trackId}`) .limit(1); return category ?? null; }