115 lines
4.0 KiB
TypeScript
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;
|
|
}
|