duoqi-api/src/services/learning/tracks-service.ts

115 lines
4.0 KiB
TypeScript

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<Record<string, string>> = 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<Map<string, ChapterProgressRow>> {
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<ThemeTrackDto[]> {
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<ThemeTrackDto | null> {
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<CategoryRow | null> {
const [category] = await db
.select()
.from(categories)
.where(sql`${categories.id} = ${trackId} OR ${categories.slug} = ${trackId}`)
.limit(1);
return category ?? null;
}