Persist learn progress per user

This commit is contained in:
Remi
2025-09-18 23:28:02 +02:00
committed by itsrubberduck
parent 19dc5cb66a
commit f30d671b0f
7 changed files with 365 additions and 66 deletions

View File

@@ -0,0 +1,21 @@
import { defineNuxtRouteMiddleware, navigateTo } from '#app'
import { useAuthStore } from '~/stores/auth'
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuthStore()
if (!auth.accessToken) {
await auth.tryRefresh().catch(() => false)
}
if (!auth.initialized) {
await auth.fetchUser().catch(() => null)
} else if (!auth.user) {
await auth.fetchUser().catch(() => null)
}
if (!auth.user) {
const redirect = encodeURIComponent(to.fullPath || '/')
return navigateTo(`/login?redirect=${redirect}`)
}
})

View File

@@ -413,8 +413,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { createDefaultLearnConfig } from '~~/shared/learn/config'
import type { LearnConfig, LearnProgress, LearnState } from '~~/shared/learn/config'
definePageMeta({ middleware: 'require-auth' })
type BlankWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
@@ -1806,81 +1810,142 @@ const sayCache = new Map<string, string>()
const pendingSayRequests = new Map<string, Promise<string>>()
const audioReveal = ref(true)
const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value)
const toast = ref({ show: false, text: '' })
const showSettings = ref(false)
const api = useApi()
const isClient = typeof window !== 'undefined'
function readStorage<T>(key: string, fallback: T): T {
if (!isClient) return fallback
try {
const raw = localStorage.getItem(key)
if (!raw) return fallback
const parsed = JSON.parse(raw) as T
return parsed ?? fallback
} catch {
return fallback
}
}
function readNumber(key: string, fallback: number): number {
if (!isClient) return fallback
const raw = localStorage.getItem(key)
if (!raw) return fallback
const value = Number(raw)
return Number.isFinite(value) ? value : fallback
}
type LearnConfig = {
tts: boolean
radioLevel: number
voice: string
audioChallenge: boolean
}
const defaultCfg: LearnConfig = { tts: false, radioLevel: 4, voice: '', audioChallenge: false }
const defaultCfg = createDefaultLearnConfig()
const cfg = ref<LearnConfig>({ ...defaultCfg })
audioReveal.value = !cfg.value.audioChallenge
if (isClient) {
const storedCfg = readStorage<{ tts?: boolean; audioChallenge?: boolean }>('os_cfg', {})
const storedLevel = readStorage<{ v?: number }>('os_cfg_level', {})
const storedVoice = readStorage<{ v?: string }>('os_cfg_voice', {})
cfg.value = {
tts: storedCfg.tts ?? defaultCfg.tts,
radioLevel: storedLevel.v ?? defaultCfg.radioLevel,
voice: storedVoice.v ?? defaultCfg.voice,
audioChallenge: storedCfg.audioChallenge ?? defaultCfg.audioChallenge
}
}
audioReveal.value = !cfg.value.audioChallenge
const xp = ref(readNumber('os_xp', 0))
const xp = ref(0)
const progress = ref<LearnProgress>({})
const level = computed(() => 1 + Math.floor(xp.value / 300))
const seasonPct = computed(() => Math.min(100, Math.round((xp.value % 1000) / 10)))
type Prog = Record<string, Record<string, { best: number; done: boolean }>>
const progress = ref<Prog>(readStorage<Prog>('os_progress', {} as Prog))
const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value)
if (isClient) {
watch(progress, value => localStorage.setItem('os_progress', JSON.stringify(value)), { deep: true })
watch(xp, value => localStorage.setItem('os_xp', String(value)))
type LearnStateResponse = LearnState
const persistGeneralCfg = () => {
localStorage.setItem('os_cfg', JSON.stringify({ tts: cfg.value.tts, audioChallenge: cfg.value.audioChallenge }))
let persistTimer: ReturnType<typeof setTimeout> | null = null
const dirtyState = reactive({ xp: false, progress: false, config: false })
const savingState = ref(false)
const pendingSave = ref(false)
async function loadLearnState() {
if (!isClient) return
try {
const response = await api.get<LearnStateResponse>('/api/learn/state')
if (response) {
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 || {}) }
} else {
xp.value = 0
progress.value = {} as LearnProgress
cfg.value = { ...defaultCfg }
}
dirtyState.xp = false
dirtyState.progress = false
dirtyState.config = false
} catch (err) {
console.error('Failed to load learn state', err)
xp.value = 0
progress.value = {} as LearnProgress
cfg.value = { ...defaultCfg }
} finally {
audioReveal.value = !cfg.value.audioChallenge
}
watch(() => cfg.value.tts, persistGeneralCfg)
watch(() => cfg.value.audioChallenge, persistGeneralCfg)
watch(() => cfg.value.radioLevel, value => localStorage.setItem('os_cfg_level', JSON.stringify({ v: value })))
watch(() => cfg.value.voice, value => localStorage.setItem('os_cfg_voice', JSON.stringify({ v: value })))
}
watch(() => cfg.value.audioChallenge, () => resetAudioReveal())
function schedulePersist(immediate = false) {
if (!isClient) return
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (immediate) {
void persistLearnState(true)
return
}
persistTimer = setTimeout(() => {
persistTimer = null
void persistLearnState()
}, 800)
}
async function persistLearnState(force = false) {
if (!isClient) return
if (!force && !dirtyState.xp && !dirtyState.progress && !dirtyState.config) {
return
}
if (savingState.value) {
pendingSave.value = true
return
}
savingState.value = true
pendingSave.value = false
const payload = {
xp: Math.max(0, Math.round(xp.value)),
progress: JSON.parse(JSON.stringify(progress.value)),
config: { ...cfg.value },
}
try {
await api.put('/api/learn/state', payload)
dirtyState.xp = false
dirtyState.progress = false
dirtyState.config = false
} catch (err) {
console.error('Failed to persist learn state', err)
} finally {
savingState.value = false
if (pendingSave.value) {
pendingSave.value = false
void persistLearnState()
}
}
}
if (isClient) {
await loadLearnState()
}
const markConfigDirty = () => {
dirtyState.config = true
schedulePersist()
}
if (isClient) {
watch(progress, () => {
dirtyState.progress = true
schedulePersist()
}, { deep: true })
watch(xp, () => {
dirtyState.xp = true
schedulePersist()
})
watch(() => cfg.value.tts, markConfigDirty)
watch(() => cfg.value.audioChallenge, () => {
markConfigDirty()
resetAudioReveal()
})
watch(() => cfg.value.radioLevel, markConfigDirty)
watch(() => cfg.value.voice, markConfigDirty)
}
onBeforeUnmount(() => {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (dirtyState.xp || dirtyState.progress || dirtyState.config) {
void persistLearnState(true)
}
})
const fieldMap = computed<Record<string, LessonField>>(() => {
const map: Record<string, LessonField> = {}
@@ -2138,8 +2203,14 @@ function toastNow(text: string) {
function resetAll() {
if (!isClient) return
localStorage.clear()
location.reload()
progress.value = {} as LearnProgress
xp.value = 0
cfg.value = { ...defaultCfg }
audioReveal.value = !cfg.value.audioChallenge
dirtyState.progress = true
dirtyState.xp = true
dirtyState.config = true
schedulePersist(true)
}
const worldTiltStyle = ref<any>({})
@@ -2164,7 +2235,7 @@ async function requestSayAudio(cacheKey: string, payload: Record<string, unknown
}
const request = (async () => {
const response: any = await api.post('/api/atc/say', payload, { auth: false })
const response: any = await api.post('/api/atc/say', payload)
const audioData = response?.audio
if (!audioData?.base64) {
throw new Error('Missing audio data')

View File

@@ -8,7 +8,7 @@ import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
import { getServerRuntimeConfig } from "../../utils/runtimeConfig";
import {request} from "node:http";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
import { requireUserSession } from "../../utils/auth";
function outDir() {
@@ -125,6 +125,8 @@ export default defineEventHandler(async (event) => {
format?: AudioFmt | "smallest";
}>(event);
const user = await requireUserSession(event);
const raw = (body?.text || "").trim();
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
@@ -212,9 +214,8 @@ export default defineEventHandler(async (event) => {
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
try {
const user = await getUserFromEvent(event)
await TransmissionLog.create({
user: user?._id,
user: user._id,
role: "atc",
channel: "say",
direction: "outgoing",

View File

@@ -0,0 +1,24 @@
import { requireUserSession } from '../../utils/auth'
import { LearnProfile } from '../../models/LearnProfile'
import { createDefaultLearnConfig, createDefaultLearnState } from '~~/shared/learn/config'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const profile = await LearnProfile.findOne({ user: user._id })
if (!profile) {
return createDefaultLearnState()
}
const config = { ...createDefaultLearnConfig(), ...(profile.config || {}) }
const progress =
profile.progress && typeof profile.progress === 'object'
? JSON.parse(JSON.stringify(profile.progress))
: {}
return {
xp: typeof profile.xp === 'number' ? profile.xp : 0,
progress,
config,
}
})

View File

@@ -0,0 +1,110 @@
import { readBody } from 'h3'
import { requireUserSession } from '../../utils/auth'
import { LearnProfile } from '../../models/LearnProfile'
import type { LearnConfig, LearnProgress, LessonProgress } from '~~/shared/learn/config'
import { createDefaultLearnConfig } from '~~/shared/learn/config'
interface LearnStateUpdateBody {
xp?: number
progress?: LearnProgress
config?: Partial<LearnConfig>
}
function sanitizeProgress(input: LearnProgress | undefined): LearnProgress | undefined {
if (!input || typeof input !== 'object') {
return undefined
}
const sanitized: LearnProgress = {}
for (const [moduleId, lessons] of Object.entries(input)) {
if (!lessons || typeof lessons !== 'object') {
continue
}
const lessonProgress: Record<string, LessonProgress> = {}
for (const [lessonId, value] of Object.entries(lessons as Record<string, any>)) {
if (!value || typeof value !== 'object') {
continue
}
const bestRaw = (value as any).best
const doneRaw = (value as any).done
const best = typeof bestRaw === 'number' && Number.isFinite(bestRaw) ? Math.max(0, Math.min(100, Math.round(bestRaw))) : 0
const done = Boolean(doneRaw)
lessonProgress[lessonId] = { best, done }
}
sanitized[moduleId] = lessonProgress
}
return sanitized
}
function sanitizeConfig(input: Partial<LearnConfig> | undefined): LearnConfig | undefined {
if (!input || typeof input !== 'object') {
return undefined
}
const config = createDefaultLearnConfig()
if (typeof input.tts === 'boolean') {
config.tts = input.tts
}
if (typeof input.audioChallenge === 'boolean') {
config.audioChallenge = input.audioChallenge
}
if (typeof input.radioLevel === 'number' && Number.isFinite(input.radioLevel)) {
const level = Math.round(input.radioLevel)
config.radioLevel = Math.min(5, Math.max(1, level))
}
if (typeof input.voice === 'string') {
config.voice = input.voice.slice(0, 120)
}
return config
}
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const body = await readBody<LearnStateUpdateBody>(event)
let profile = await LearnProfile.findOne({ user: user._id })
if (!profile) {
profile = new LearnProfile({ user: user._id })
}
if (typeof body.xp === 'number' && Number.isFinite(body.xp)) {
profile.xp = Math.max(0, Math.round(body.xp))
}
const progress = sanitizeProgress(body.progress)
if (progress !== undefined) {
profile.progress = progress
}
const config = sanitizeConfig(body.config)
if (config !== undefined) {
profile.config = config
}
await profile.save()
const responseConfig = { ...createDefaultLearnConfig(), ...(profile.config || {}) }
const responseProgress =
profile.progress && typeof profile.progress === 'object'
? JSON.parse(JSON.stringify(profile.progress))
: {}
return {
xp: typeof profile.xp === 'number' ? profile.xp : 0,
progress: responseProgress,
config: responseConfig,
}
})

View File

@@ -0,0 +1,34 @@
import mongoose from 'mongoose'
import type { LearnConfig, LearnProgress } from '~~/shared/learn/config'
export interface LearnProfileDocument extends mongoose.Document {
user: mongoose.Types.ObjectId
xp: number
progress: LearnProgress
config: LearnConfig
createdAt: Date
updatedAt: Date
}
const learnProfileSchema = new mongoose.Schema<LearnProfileDocument>(
{
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, unique: true },
xp: { type: Number, default: 0 },
progress: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
config: {
tts: { type: Boolean, default: false },
radioLevel: { type: Number, default: 4, min: 1, max: 5 },
voice: { type: String, default: '', trim: true },
audioChallenge: { type: Boolean, default: false },
},
},
{
timestamps: true,
},
)
learnProfileSchema.index({ user: 1 }, { unique: true })
export const LearnProfile =
(mongoose.models.LearnProfile as mongoose.Model<LearnProfileDocument> | undefined) ||
mongoose.model<LearnProfileDocument>('LearnProfile', learnProfileSchema)

38
shared/learn/config.ts Normal file
View File

@@ -0,0 +1,38 @@
export interface LessonProgress {
best: number
done: boolean
}
export type LearnProgress = Record<string, Record<string, LessonProgress>>
export interface LearnConfig {
tts: boolean
radioLevel: number
voice: string
audioChallenge: boolean
}
export interface LearnState {
xp: number
progress: LearnProgress
config: LearnConfig
}
export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
tts: false,
radioLevel: 4,
voice: '',
audioChallenge: false,
}
export function createDefaultLearnConfig(): LearnConfig {
return { ...LEARN_CONFIG_DEFAULTS }
}
export function createDefaultLearnState(): LearnState {
return {
xp: 0,
progress: {} as LearnProgress,
config: createDefaultLearnConfig(),
}
}