Files
lms-be/src/curriculum/curriculum.service.ts
T
2026-07-07 16:09:12 +09:00

930 lines
31 KiB
TypeScript

import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { join } from 'node:path';
import { AuthService } from '../auth/auth.service';
import { PlatformControlService } from '../platform-control/platform-control.service';
import { PrismaService } from '../prisma/prisma.service';
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
@Injectable()
export class CurriculumService {
constructor(
private readonly authService: AuthService,
private readonly platformControlService: PlatformControlService,
private readonly prisma: PrismaService,
) {}
async listCourses(accessToken?: string) {
const authUser = accessToken
? await this.authService.me(accessToken).then((response) => response.user).catch(() => null)
: null;
const lmsUser = authUser?.email
? await this.prisma.user.findUnique({ where: { email: authUser.email } })
: null;
if (!lmsUser) {
const guideCourse = await this.findPublicGuideCourse();
return {
courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [],
};
}
const enrollments = await this.prisma.studentEnrollment.findMany({
where: {
userId: lmsUser.id,
status: 'ACTIVE',
course: { isActive: true },
},
include: {
course: {
include: {
teacher: {
select: {
id: true,
email: true,
name: true,
primaryOrganizationName: true,
},
},
studentProgress: {
where: { userId: lmsUser.id },
take: 1,
},
},
},
},
orderBy: { updatedAt: 'desc' },
});
if (enrollments.length) {
return {
courses: enrollments.map((enrollment) => this.serializeCourseListItem(enrollment.course)),
};
}
const guideCourse = await this.findPublicGuideCourse(lmsUser.id);
return {
courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [],
};
}
async getActiveCurriculum(accessToken?: string, courseId?: string | null) {
const authUser = accessToken
? await this.authService.me(accessToken).then((response) => response.user).catch(() => null)
: null;
const lmsUser = authUser?.email
? await this.prisma.user.findUnique({ where: { email: authUser.email } })
: null;
const requestedCourseId = courseId?.trim();
const enrolledCourse = !requestedCourseId && authUser?.email
? await this.prisma.course.findFirst({
where: {
isActive: true,
enrollments: {
some: {
user: { email: authUser.email },
status: 'ACTIVE',
},
},
},
select: { id: true },
orderBy: { updatedAt: 'desc' },
})
: null;
const course = await this.prisma.course.findFirst({
where: {
...(requestedCourseId
? {
id: requestedCourseId,
...(authUser?.email
? {
OR: [
{ slug: PUBLIC_GUIDE_COURSE_SLUG },
{
enrollments: {
some: {
user: { email: authUser.email },
status: 'ACTIVE',
},
},
},
],
}
: { slug: PUBLIC_GUIDE_COURSE_SLUG }),
}
: enrolledCourse
? { id: enrolledCourse.id }
: { slug: PUBLIC_GUIDE_COURSE_SLUG }),
isActive: true,
},
include: {
teacher: {
select: {
id: true,
email: true,
name: true,
primaryOrganizationName: true,
},
},
weeks: {
orderBy: { week: 'asc' },
include: {
topics: {
orderBy: { sortOrder: 'asc' },
},
practices: {
orderBy: { sortOrder: 'asc' },
},
references: {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
},
modules: {
orderBy: { sortOrder: 'asc' },
},
},
},
platforms: {
orderBy: { sortOrder: 'asc' },
include: {
platform: true,
},
},
environmentProfiles: {
where: { enabled: true },
},
studentProgress: {
where: {
user: {
email: authUser?.email ?? '__NO_AUTH_USER__',
},
},
take: 1,
},
},
});
if (!course) {
return emptyCurriculumResponse();
}
const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({
where: {
courseId: course.id,
enabled: true,
},
include: {
platform: true,
},
});
const environmentProfileByWeekType = new Map(course.environmentProfiles.map((profile) => [`${profile.week}:${profile.profileType.toUpperCase()}`, profile]));
const visualizationProfilesByWeek = new Map<number, Array<{
id: string;
week: number;
platformKey: string;
profileType: string;
name: string;
config: Prisma.JsonValue;
}>>();
visualizationSelections.forEach((selection) => {
const profileType = selection.platform.type.toUpperCase();
const environmentProfile = environmentProfileByWeekType.get(`${selection.week}:${profileType}`);
const items = visualizationProfilesByWeek.get(selection.week) ?? [];
items.push({
id: environmentProfile?.id ?? selection.id,
week: selection.week,
platformKey: environmentProfile?.platformKey ?? 'futuredrive-local-pc',
profileType: selection.platform.type,
name: environmentProfile?.name ?? selection.platform.displayName,
config: environmentProfile?.config ?? selection.platform.defaultConfig,
});
visualizationProfilesByWeek.set(selection.week, items);
});
const progress = course.studentProgress[0];
const enrollment = lmsUser
? await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId: course.id,
status: 'ACTIVE',
},
})
: null;
const organizationName = enrollment?.organizationName ?? course.organizationName;
const organizationId = enrollment?.organizationId ?? undefined;
const studentGroup = lmsUser
? await this.prisma.studentGroup.findFirst({
where: {
organizationName,
members: {
some: {
userId: lmsUser.id,
user: {
enrollments: {
some: {
courseId: course.id,
status: 'ACTIVE',
},
},
},
},
},
},
select: {
id: true,
name: true,
},
})
: null;
const assignedInstructorPlatformIds = await this.findStudentAssignedInstructorPlatformIds(
organizationId,
organizationName,
authUser?.email,
authUser?.id,
);
const instructorPlatforms = await this.findStudentPlatformOptions(
organizationId,
organizationName,
assignedInstructorPlatformIds,
);
const environmentProfilesByPlatformKey = new Map<string, Array<{
id: string;
week: number;
platformKey: string;
profileType: string;
name: string;
config: Prisma.JsonValue;
}>>();
Array.from(visualizationProfilesByWeek.values()).flat().forEach((profile) => {
const items = environmentProfilesByPlatformKey.get(profile.platformKey) ?? [];
items.push({
id: profile.id,
week: profile.week,
platformKey: profile.platformKey,
profileType: profile.profileType,
name: profile.name,
config: profile.config,
});
environmentProfilesByPlatformKey.set(profile.platformKey, items);
});
const fixedPlatforms = course.platforms
.filter((coursePlatform) => {
return this.isFixedPlatform(coursePlatform.platform);
})
.map((coursePlatform) => {
const isLocal = this.isFixedPlatform(coursePlatform.platform);
return {
id: coursePlatform.platform.id,
platformKey: coursePlatform.platform.platformKey,
title: coursePlatform.platform.displayName,
description: coursePlatform.platform.description,
organizationName: coursePlatform.platform.organizationName,
iconKey: coursePlatform.platform.iconKey,
sourceSettings: coursePlatform.platform.defaultStudioSettings,
environmentProfiles: environmentProfilesByPlatformKey.get(coursePlatform.platform.platformKey) ?? [],
status: isLocal ? '사용 가능' : coursePlatform.status,
action: isLocal ? '연결' : coursePlatform.action,
state: coursePlatform.state,
local: isLocal,
};
});
const controlStates = await this.platformControlService.getControlStates(
{ organizationId, organizationName },
lmsUser?.id ?? null,
[
...fixedPlatforms.filter((platform) => !platform.local).map((platform) =>
this.platformControlService.makePlatformResource(platform.id, null),
),
...instructorPlatforms.map((platform) =>
this.platformControlService.makePlatformResource(null, platform.id),
),
],
);
const platforms = [
...fixedPlatforms.map((platform) => {
const { local: _local, ...payload } = platform;
return platform.local
? payload
: this.withControlState(
payload,
controlStates.get(this.platformControlService.resourceKeyForIds(platform.id, null)),
);
}),
...instructorPlatforms.map((platform) => this.withControlState(
platform,
controlStates.get(this.platformControlService.resourceKeyForIds(null, platform.id)),
)),
];
const groupSetting = studentGroup
? await this.prisma.instructorCurriculumSetting.findFirst({
where: {
organizationName,
courseId: course.id,
groupId: studentGroup.id,
},
select: {
activeWeek: true,
activeLearningStep: true,
},
})
: null;
const defaultSetting = await this.prisma.instructorCurriculumSetting.findFirst({
where: {
organizationName,
courseId: course.id,
groupId: null,
},
select: {
activeWeek: true,
activeLearningStep: true,
},
});
const setting = groupSetting ?? defaultSetting;
const overrides = await this.prisma.instructorCurriculumWeekOverride.findMany({
where: {
organizationName,
courseId: course.id,
},
});
const overrideByWeek = new Map(overrides.map((override) => [override.week, override]));
const weekProgressItems = lmsUser
? await this.prisma.courseStudentWeekProgress.findMany({
where: {
courseId: course.id,
userId: lmsUser.id,
},
})
: [];
const weekProgressByWeek = new Map(weekProgressItems.map((item) => [item.week, item]));
const baseWeekNumbers = new Set(course.weeks.map((week) => week.week));
const baseWeeks = course.weeks.map((week) => {
const override = overrideByWeek.get(week.week);
const weekProgress = weekProgressByWeek.get(week.week);
const disabledModuleIdSet = new Set(this.stringArray(override?.disabledModuleIds) ?? []);
const learningSteps = this.resolveLearningSteps(
override?.learningSteps,
this.stringArray(override?.topics) ?? week.topics.map((topic) => topic.title),
this.stringArray(override?.topicDetails) ?? week.topics.map((topic) => topic.detail),
this.stringArray(override?.topicVideoUrls) ?? week.topics.map(() => ''),
);
const topics = learningSteps.map((step) => step.title);
const topicDetails = learningSteps.map((step) => step.description);
const topicVideoUrls = learningSteps.map((step) => step.videoUrl);
const practiceContents = this.practiceContentsArray(override?.practiceContents)
?? (week.practices.length
? week.practices.map((practice) => ({
title: practice.title,
description: practice.description,
}))
: undefined);
const progressState = this.resolveWeekProgressState(week.week, week.state, topics, setting);
return {
week: week.week,
title: override?.title ?? week.title,
tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state),
progress: progressState.progress,
state: progressState.state,
topics,
duration: override?.duration ?? week.duration,
github: week.github,
detail: {
overview: override?.overview ?? week.overview,
learningSteps,
topicDetails,
topicVideoUrls,
practiceContents,
references: week.references.filter((reference) => reference.kind === 'REFERENCE').map((reference) => this.serializeWeekReference(reference, week.week)),
exampleCodes: week.references.filter((reference) => reference.kind === 'EXAMPLE_CODE').map((reference) => this.serializeWeekReference(reference, week.week)),
deliverable: override?.deliverable ?? week.deliverable,
requiresSourceSubmission: override?.requiresSourceSubmission ?? week.requiresSourceSubmission,
sourceSubmitted: Boolean(weekProgress?.submittedAt),
sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null,
},
modules: week.modules
.filter((module) => !disabledModuleIdSet.has(module.id))
.map((module) => {
const moduleWithArtifacts = module as typeof module & {
sourceType?: string | null;
prebuiltArtifacts?: unknown;
};
return {
id: module.id,
sortOrder: module.sortOrder,
category: module.category,
name: module.name,
folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sourceType: moduleWithArtifacts.sourceType ?? 'GIT',
prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [],
dockerImage: module.dockerImage,
};
}),
environmentProfiles: visualizationProfilesByWeek.get(week.week) ?? [],
};
});
const addedWeeks = overrides
.filter((override) => !baseWeekNumbers.has(override.week))
.map((override) => {
const learningSteps = this.resolveLearningSteps(
override.learningSteps,
this.stringArray(override.topics) ?? ['학습단계 1'],
this.stringArray(override.topicDetails) ?? [''],
this.stringArray(override.topicVideoUrls) ?? [''],
);
const topics = learningSteps.map((step) => step.title);
const progressState = this.resolveWeekProgressState(override.week, 'ready', topics, setting);
const weekProgress = weekProgressByWeek.get(override.week);
return {
week: override.week,
title: override.title ?? `${override.week}주차 커리큘럼`,
tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state),
progress: progressState.progress,
state: progressState.state,
topics,
duration: override.duration ?? '-',
github: '-',
detail: {
overview: override.overview ?? '',
learningSteps,
topicDetails: learningSteps.map((step) => step.description),
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
practiceContents: this.practiceContentsArray(override.practiceContents) ?? undefined,
references: [],
deliverable: override.deliverable ?? '',
requiresSourceSubmission: override.requiresSourceSubmission ?? true,
sourceSubmitted: Boolean(weekProgress?.submittedAt),
sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null,
},
environmentProfiles: visualizationProfilesByWeek.get(override.week) ?? [],
};
});
return {
course: {
id: course.id,
slug: course.slug,
title: course.title,
subtitle: course.subtitle,
description: course.description,
organizationName: course.organizationName,
teacher: course.teacher,
},
weeks: [...baseWeeks, ...addedWeeks].sort((left, right) => left.week - right.week),
activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? null,
activeLearningStep: setting?.activeLearningStep ?? null,
learningStats: this.serializeLearningStats(progress, course.weeks.length),
platforms,
};
}
async getReferenceDownload(accessToken: string | undefined, referenceId: string) {
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
const reference = await this.prisma.courseWeekReference.findUnique({
where: { id: referenceId },
include: {
week: {
include: {
course: {
include: {
enrollments: {
where: {
status: 'ACTIVE',
user: { email: authUser.email },
},
take: 1,
},
},
},
},
},
},
});
if (!reference || !reference.week.course.enrollments.length) {
throw new NotFoundException('Reference not found');
}
return {
path: join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName),
fileName: reference.originalFileName,
contentType: reference.contentType ?? 'application/octet-stream',
};
}
private withControlState(
platform: {
id: string;
platformKey: string;
title: string;
description: string;
organizationName: string;
iconKey: string;
status: string;
action: string;
state: string;
},
controlState:
| {
status: string;
action: string;
state: string;
session: unknown;
queue: unknown;
warning: boolean;
}
| undefined,
) {
if (!controlState) return platform;
return {
...platform,
status: controlState.status === 'AVAILABLE' ? platform.status : this.toControlStatusLabel(controlState.status),
action: controlState.status === 'AVAILABLE' ? platform.action : controlState.action,
state: controlState.state,
control: controlState,
};
}
private async findPublicGuideCourse(userId?: string) {
return this.prisma.course.findFirst({
where: {
slug: PUBLIC_GUIDE_COURSE_SLUG,
isActive: true,
},
include: {
teacher: {
select: {
id: true,
email: true,
name: true,
primaryOrganizationName: true,
},
},
studentProgress: {
where: { userId: userId ?? '00000000-0000-0000-0000-000000000000' },
take: 1,
},
},
});
}
private serializeCourseListItem(
course: {
id: string;
slug: string;
title: string;
subtitle: string | null;
description: string | null;
organizationName: string;
teacher: {
id: string;
email: string;
name: string | null;
primaryOrganizationName: string | null;
};
studentProgress: Array<{
completedWeeks: number;
totalWeeks: number;
}>;
},
publicGuide = false,
) {
const progress = course.studentProgress[0];
return {
id: course.id,
slug: course.slug,
title: course.title,
subtitle: course.subtitle,
description: course.description,
organizationName: course.organizationName,
teacher: course.teacher,
progress: progress && progress.totalWeeks > 0
? Math.round((progress.completedWeeks / progress.totalWeeks) * 100)
: 0,
publicGuide,
};
}
private serializeLearningStats(
progress: {
completedWeeks: number;
totalWeeks: number;
learningHours: number;
commitCount: number;
averageScore: number;
} | null | undefined,
fallbackTotalWeeks: number,
) {
const completedWeeks = progress?.completedWeeks ?? 0;
const totalWeeks = progress?.totalWeeks ?? fallbackTotalWeeks;
const learningHours = progress?.learningHours ?? 0;
const commitCount = progress?.commitCount ?? 0;
const averageScore = progress?.averageScore ?? 0;
return [
{
key: 'completedWeeks',
value: `${completedWeeks}/${totalWeeks}`,
label: '완료한 주차',
iconKey: 'check',
},
{
key: 'learningHours',
value: this.formatLearningHours(learningHours),
label: '학습 시간',
iconKey: 'clock',
},
{
key: 'commitCount',
value: String(commitCount),
label: '커밋 수',
iconKey: 'trending',
},
{
key: 'averageScore',
value: String(averageScore),
label: '평균 점수',
iconKey: 'gauge',
},
];
}
private toControlStatusLabel(status: string) {
if (status === 'ACTIVE') return '제어 중';
if (status === 'QUEUED') return '대기 예약됨';
if (status === 'IN_USE') return '다른 수강생 사용 중';
return '사용 가능';
}
private async findStudentAssignedInstructorPlatformIds(
organizationId: string | undefined,
organizationName: string,
email: string | undefined,
authUserId: string | undefined,
) {
const userConditions = [
email ? { email } : undefined,
authUserId ? { authUserId } : undefined,
].filter(Boolean) as Prisma.UserWhereInput[];
if (!userConditions.length) return [];
const groups = await this.prisma.studentGroup.findMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: { not: null },
members: {
some: {
user: {
OR: userConditions,
},
},
},
},
select: {
instructorPlatformId: true,
},
});
const assignedInstructorPlatformIds = [
...new Set(
groups
.map((group) => group.instructorPlatformId)
.filter((platformId): platformId is string => Boolean(platformId)),
),
];
return assignedInstructorPlatformIds;
}
private async findStudentPlatformOptions(
organizationId: string | undefined,
organizationName: string,
assignedInstructorPlatformIds: string[],
) {
if (!assignedInstructorPlatformIds.length) return [];
const platforms = await this.prisma.instructorPlatform.findMany({
where: {
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
id: { in: assignedInstructorPlatformIds },
platform: {
is: {
isActive: true,
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
],
},
},
},
orderBy: [{ displayName: 'asc' }],
include: { platform: true },
});
return platforms.map((platform) => {
const blocked = platform.emergencyStop || platform.errorCount > 0;
return {
id: platform.id,
platformKey: platform.platform.platformKey,
title: platform.displayName,
description: platform.platform.description,
organizationName: platform.organizationName,
iconKey: platform.platform.iconKey,
sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings,
status: blocked
? platform.emergencyStop
? '비상 정지'
: '오류 발생'
: '상태 확인 중',
action: blocked ? '연결 불가' : '제어권 요청',
state: blocked ? 'locked' : 'ready',
};
});
}
private organizationWhere<T>(
organizationId: string | undefined,
organizationName: string | undefined,
): T {
return {
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean),
} as T;
}
private isFixedPlatform(platform: { platformKey: string; displayName: string }) {
const platformKey = platform.platformKey.toLowerCase();
const displayName = platform.displayName.toLowerCase();
return (
platformKey.includes('local') ||
displayName.includes('로컬')
);
}
private stringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
return value.filter((item): item is string => typeof item === 'string');
}
private resolveLearningSteps(
value: unknown,
topics: string[],
topicDetails: string[] = [],
topicVideoUrls: string[] = [],
): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> {
return this.normalizeLearningSteps(value) ?? this.normalizeLearningSteps(undefined, topics, topicDetails, topicVideoUrls) ?? [];
}
private normalizeLearningSteps(
value?: unknown,
topics: string[] = [],
topicDetails: string[] = [],
topicVideoUrls: string[] = [],
): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> | null {
if (Array.isArray(value)) {
const steps = value
.map((item) => {
if (!item || typeof item !== 'object') return null;
const record = item as Record<string, unknown>;
const title = typeof record.title === 'string' ? record.title.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
const videoUrl = typeof record.videoUrl === 'string' ? record.videoUrl.trim() : '';
const videoEnabled = typeof record.videoEnabled === 'boolean' ? record.videoEnabled : Boolean(videoUrl);
const concepts = Array.isArray(record.concepts)
? record.concepts.filter((concept): concept is string => typeof concept === 'string').map((concept) => concept.trim()).filter(Boolean)
: [];
return title || description || videoUrl || concepts.length
? { title: title || '학습단계', description, videoEnabled, videoUrl: videoEnabled ? videoUrl : '', concepts }
: null;
})
.filter((item): item is { title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] } => Boolean(item));
return steps.length ? steps : null;
}
const titles = topics.map((topic) => topic.trim()).filter(Boolean);
if (!titles.length) return null;
return titles.map((title, index) => {
const videoUrl = topicVideoUrls[index]?.trim() ?? '';
return {
title,
description: topicDetails[index]?.trim() ?? '',
videoEnabled: Boolean(videoUrl),
videoUrl,
concepts: [],
};
});
}
private practiceContentsArray(value: unknown): Array<{ title: string; description: string }> | null {
if (!Array.isArray(value)) return null;
return value
.map((item) => {
if (!item || typeof item !== 'object') return null;
const record = item as Record<string, unknown>;
const title = typeof record.title === 'string' ? record.title.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
return title || description ? { title, description } : null;
})
.filter((item): item is { title: string; description: string } => Boolean(item));
}
private serializeWeekReference(
reference: {
id: string;
kind: string;
title: string;
originalFileName: string;
contentType: string | null;
fileSizeBytes: number;
createdAt: Date;
},
week: number,
) {
return {
id: reference.id,
kind: reference.kind,
title: reference.title,
description: reference.originalFileName,
fileName: reference.originalFileName,
contentType: reference.contentType,
fileSizeBytes: reference.fileSizeBytes,
week,
weekNumber: week,
downloadUrl: `/curriculum/references/${reference.id}/download`,
createdAt: reference.createdAt.toISOString(),
};
}
private resolveWeekProgressState(
week: number,
baseState: string,
topics: string[],
setting: { activeWeek: number; activeLearningStep?: string | null } | null | undefined,
) {
if (!setting) return { state: baseState, progress: baseState === 'done' ? 100 : 0 };
if (week < setting.activeWeek) return { state: 'done', progress: 100 };
if (week > setting.activeWeek) return { state: 'locked', progress: 0 };
const progress = this.resolveLearningStepProgress(topics, setting.activeLearningStep);
return {
state: progress >= 100 ? 'done' : 'current',
progress,
};
}
private resolveLearningStepProgress(topics: string[], activeLearningStep?: string | null) {
if (!topics.length) return activeLearningStep === null ? 100 : 0;
if (activeLearningStep === null) return 100;
const activeIndex = topics.findIndex((topic) => topic === activeLearningStep);
if (activeIndex < 0) return 0;
return Math.min(99, Math.round((activeIndex / topics.length) * 100));
}
private resolveWeekTags(tags: string[], state: string) {
const nextTags = tags.length ? [...tags] : ['커리큘럼'];
nextTags[1] = state === 'done' ? '완료' : state === 'current' ? '진행중' : state === 'locked' ? '잠김' : '대기';
return nextTags;
}
private resolveBaseWeekTags(tags: string[], tagLabel?: string | null) {
const nextTags = tags.length ? [...tags] : ['커리큘럼'];
const label = tagLabel?.trim();
if (label) nextTags[0] = label;
return nextTags;
}
private formatLearningHours(learningMinutes: number) {
const totalMinutes = Math.max(0, Math.floor(learningMinutes));
if (totalMinutes < 60) return `${totalMinutes}m`;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
}
function emptyCurriculumResponse() {
return {
course: null,
weeks: [],
learningStats: [],
platforms: [],
};
}