diff --git a/app/pages/learn.vue b/app/pages/learn.vue index 9044c6b..b009d48 100644 --- a/app/pages/learn.vue +++ b/app/pages/learn.vue @@ -86,7 +86,15 @@ mdi-lock-alert
Clearance pending
-
Complete earlier missions to unlock this briefing.
+
+ Complete earlier missions to + + this briefing. +
@@ -764,6 +772,10 @@
{{ l.desc }}
+ {{ lessonOrderTag(current.id, l.id) }} {{ k }}
@@ -2348,6 +2360,7 @@ audioReveal.value = !cfg.value.audioChallenge const XP_PER_LEVEL = 300 const xp = ref(0) const progress = ref({}) +const unlockedModules = ref([]) const level = computed(() => 1 + Math.floor(xp.value / XP_PER_LEVEL)) const nextLevel = computed(() => level.value + 1) const levelProgress = computed(() => Math.min(100, Math.round(((xp.value % XP_PER_LEVEL) / XP_PER_LEVEL) * 100))) @@ -2419,8 +2432,17 @@ const currentBriefingArt = computed(() => current.value?.meta?.briefingArt || '/ type LearnStateResponse = LearnState +function sanitizeModuleList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const sanitized = value + .filter(item => typeof item === 'string') + .map(item => item.trim()) + .filter(item => item.length > 0) + return Array.from(new Set(sanitized)) +} + let persistTimer: ReturnType | null = null -const dirtyState = reactive({xp: false, progress: false, config: false}) +const dirtyState = reactive({xp: false, progress: false, config: false, unlocked: false}) const savingState = ref(false) const pendingSave = ref(false) @@ -2433,19 +2455,23 @@ async function loadLearnState() { xp.value = Number.isFinite(response.xp) ? Math.max(0, Math.round(response.xp)) : 0 progress.value = (response.progress ?? {}) as LearnProgress cfg.value = {...defaultCfg, ...(response.config || {})} + unlockedModules.value = sanitizeModuleList(response.unlockedModules) } else { xp.value = 0 progress.value = {} as LearnProgress cfg.value = {...defaultCfg} + unlockedModules.value = [] } dirtyState.xp = false dirtyState.progress = false dirtyState.config = false + dirtyState.unlocked = false } catch (err) { console.error('Failed to load learn state', err) xp.value = 0 progress.value = {} as LearnProgress cfg.value = {...defaultCfg} + unlockedModules.value = [] } finally { audioReveal.value = !cfg.value.audioChallenge } @@ -2469,7 +2495,7 @@ function schedulePersist(immediate = false) { async function persistLearnState(force = false) { if (!isClient) return - if (!force && !dirtyState.xp && !dirtyState.progress && !dirtyState.config) { + if (!force && !dirtyState.xp && !dirtyState.progress && !dirtyState.config && !dirtyState.unlocked) { return } if (savingState.value) { @@ -2483,6 +2509,7 @@ async function persistLearnState(force = false) { xp: Math.max(0, Math.round(xp.value)), progress: JSON.parse(JSON.stringify(progress.value)), config: {...cfg.value}, + unlockedModules: [...unlockedModules.value], } try { @@ -2490,6 +2517,7 @@ async function persistLearnState(force = false) { dirtyState.xp = false dirtyState.progress = false dirtyState.config = false + dirtyState.unlocked = false } catch (err) { console.error('Failed to persist learn state', err) } finally { @@ -2519,6 +2547,10 @@ if (isClient) { dirtyState.xp = true schedulePersist() }) + watch(unlockedModules, () => { + dirtyState.unlocked = true + schedulePersist() + }, {deep: true}) watch(() => cfg.value.tts, markConfigDirty) watch(() => cfg.value.audioChallenge, () => { markConfigDirty() @@ -2952,12 +2984,25 @@ function evaluate() { } function isModuleUnlocked(id: string) { + if (unlockedModules.value.includes(id)) return true if (id === 'normalize') return true const order = modules.value.findIndex(module => module.id === id) const previous = modules.value[order - 1] return previous ? pct(previous.id) >= 80 : true } +function attemptUnlockModule(modId: string) { + if (!isClient) return + if (!modId) return + if (isModuleUnlocked(modId)) return + const confirmed = window.confirm('Unlock this mission briefing early?') + if (!confirmed) return + const next = new Set(unlockedModules.value) + next.add(modId) + unlockedModules.value = Array.from(next) + toastNow('Mission unlocked') +} + function openModule(id: string, options: { autoStart?: boolean } = {}) { const module = modules.value.find(item => item.id === id) || null current.value = module @@ -3073,6 +3118,25 @@ function pct(modId: string) { return Math.round((doneCount(modId) / module.lessons.length) * 100) } +function moduleNumber(modId: string): number { + const index = modules.value.findIndex(module => module.id === modId) + return index >= 0 ? index + 1 : 0 +} + +function lessonNumber(modId: string, lesId: string): number { + const module = modules.value.find(item => item.id === modId) + if (!module) return 0 + const index = module.lessons.findIndex(lesson => lesson.id === lesId) + return index >= 0 ? index + 1 : 0 +} + +function lessonOrderTag(modId: string, lesId: string): string { + const modNum = moduleNumber(modId) + const lesNum = lessonNumber(modId, lesId) + if (!modNum || !lesNum) return '' + return `${modNum}.${lesNum}` +} + function avgScore(modId: string) { const module = modules.value.find(item => item.id === modId) if (!module) return 0 @@ -3285,10 +3349,12 @@ function resetAll() { progress.value = {} as LearnProgress xp.value = 0 cfg.value = {...defaultCfg} + unlockedModules.value = [] audioReveal.value = !cfg.value.audioChallenge dirtyState.progress = true dirtyState.xp = true dirtyState.config = true + dirtyState.unlocked = true schedulePersist(true) } @@ -4399,6 +4465,25 @@ onMounted(() => { color: rgba(248, 250, 252, .75) } +.tile-overlay-link { + pointer-events: auto; + background: none; + border: none; + padding: 0; + margin: 0 2px; + font: inherit; + color: inherit; + cursor: pointer; + text-decoration: none; +} + +.tile-overlay-link:hover, +.tile-overlay-link:focus-visible { + text-decoration: underline; + color: #f8fafc; + outline: none; +} + .line { height: 8px; border: 1px solid var(--border); @@ -4886,6 +4971,13 @@ onMounted(() => { padding: 2px 6px } +.tag-index { + border-style: solid; + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + color: var(--accent); + font-weight: 600; +} + .console { display: flex; flex-direction: column; diff --git a/server/api/learn/state.get.ts b/server/api/learn/state.get.ts index 470d786..e942989 100644 --- a/server/api/learn/state.get.ts +++ b/server/api/learn/state.get.ts @@ -15,10 +15,14 @@ export default defineEventHandler(async (event) => { profile.progress && typeof profile.progress === 'object' ? JSON.parse(JSON.stringify(profile.progress)) : {} + const unlockedModules = Array.isArray(profile.unlockedModules) + ? Array.from(new Set((profile.unlockedModules as unknown[]).filter(item => typeof item === 'string').map(item => item.trim()))) + : [] return { xp: typeof profile.xp === 'number' ? profile.xp : 0, progress, config, + unlockedModules, } }) diff --git a/server/api/learn/state.put.ts b/server/api/learn/state.put.ts index 5f2941c..143fb64 100644 --- a/server/api/learn/state.put.ts +++ b/server/api/learn/state.put.ts @@ -8,6 +8,7 @@ interface LearnStateUpdateBody { xp?: number progress?: LearnProgress config?: Partial + unlockedModules?: string[] } function sanitizeProgress(input: LearnProgress | undefined): LearnProgress | undefined { @@ -76,6 +77,19 @@ function sanitizeConfig(input: Partial | undefined): LearnConfig | return config } +function sanitizeUnlockedModules(input: string[] | undefined): string[] | undefined { + if (!Array.isArray(input)) { + return undefined + } + + const sanitized = input + .filter(value => typeof value === 'string') + .map(value => value.trim()) + .filter(value => value.length > 0) + + return Array.from(new Set(sanitized)) +} + export default defineEventHandler(async (event) => { const user = await requireUserSession(event) const body = await readBody(event) @@ -99,6 +113,11 @@ export default defineEventHandler(async (event) => { profile.config = config } + const unlockedModules = sanitizeUnlockedModules(body.unlockedModules) + if (unlockedModules !== undefined) { + profile.unlockedModules = unlockedModules + } + await profile.save() const responseConfig = { ...createDefaultLearnConfig(), ...(profile.config || {}) } @@ -106,10 +125,14 @@ export default defineEventHandler(async (event) => { profile.progress && typeof profile.progress === 'object' ? JSON.parse(JSON.stringify(profile.progress)) : {} + const responseUnlockedModules = Array.isArray(profile.unlockedModules) + ? Array.from(new Set((profile.unlockedModules as unknown[]).filter(item => typeof item === 'string').map(item => item.trim()))) + : [] return { xp: typeof profile.xp === 'number' ? profile.xp : 0, progress: responseProgress, config: responseConfig, + unlockedModules: responseUnlockedModules, } }) diff --git a/server/models/LearnProfile.ts b/server/models/LearnProfile.ts index 3190c57..0aa3312 100644 --- a/server/models/LearnProfile.ts +++ b/server/models/LearnProfile.ts @@ -6,6 +6,7 @@ export interface LearnProfileDocument extends mongoose.Document { xp: number progress: LearnProgress config: LearnConfig + unlockedModules: string[] createdAt: Date updatedAt: Date } @@ -15,6 +16,7 @@ const learnProfileSchema = new mongoose.Schema( user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, unique: true }, xp: { type: Number, default: 0 }, progress: { type: mongoose.Schema.Types.Mixed, default: () => ({}) }, + unlockedModules: { type: [String], default: () => [] }, config: { tts: { type: Boolean, default: false }, radioLevel: { type: Number, default: 4, min: 1, max: 5 }, diff --git a/shared/learn/config.ts b/shared/learn/config.ts index c4df32d..3f4f2fe 100644 --- a/shared/learn/config.ts +++ b/shared/learn/config.ts @@ -17,6 +17,7 @@ export interface LearnState { xp: number progress: LearnProgress config: LearnConfig + unlockedModules: string[] } export const LEARN_CONFIG_DEFAULTS: LearnConfig = { @@ -36,5 +37,6 @@ export function createDefaultLearnState(): LearnState { xp: 0, progress: {} as LearnProgress, config: createDefaultLearnConfig(), + unlockedModules: [], } }