diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index ab2e124..c42359a 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -38,6 +38,61 @@
Start with the ICAO alphabet & numbers, then basics, ground, and more.
+ + +
+
+ {{ lessonSearchResults.length }} {{ lessonSearchResults.length === 1 ? 'match' : 'matches' }} + +
+
+ +
+

No lessons found for “{{ lessonSearch }}”.

+
+
(learnModules) +type LessonSearchHit = { + module: ModuleDef + lesson: Lesson + score: number +} + +const lessonSearch = ref('') +const normalizedLessonSearch = computed(() => norm(lessonSearch.value)) +const hasLessonSearch = computed(() => normalizedLessonSearch.value.length > 0) +const lessonSearchResults = computed(() => { + const query = normalizedLessonSearch.value + if (!query) return [] + const terms = query.split(' ').filter(Boolean) + const results: LessonSearchHit[] = [] + + modules.value.forEach(module => { + module.lessons.forEach(lesson => { + const searchable = [lesson.title, lesson.desc, module.title, module.subtitle, (lesson.keywords || []).join(' ')] + .filter(Boolean) + .join(' ') + const haystack = norm(searchable) + const matchesTerms = terms.every(term => haystack.includes(term)) + const scores = [ + similarity(lesson.title, lessonSearch.value), + similarity(lesson.desc || '', lessonSearch.value), + similarity(module.title, lessonSearch.value), + similarity(module.subtitle || '', lessonSearch.value), + similarity((lesson.keywords || []).join(' '), lessonSearch.value) + ] + const bestScore = Math.max(...scores) + if (!matchesTerms && bestScore <= 0.45) { + return + } + const boost = matchesTerms ? 0.2 : 0 + results.push({ + module, + lesson, + score: Math.min(1, bestScore + boost) + }) + }) + }) + + return results + .sort((a, b) => { + if (b.score === a.score) { + const moduleCompare = a.module.title.localeCompare(b.module.title) + if (moduleCompare !== 0) return moduleCompare + return a.lesson.title.localeCompare(b.lesson.title) + } + return b.score - a.score + }) + .slice(0, 20) +}) + type FlightPlanMode = 'random' | 'manual' | 'simbrief' type MissionPlanState = { scenario: Scenario; mode: FlightPlanMode; updatedAt: number } type MissionPlanInput = { @@ -1316,6 +1425,7 @@ const current = ref(null) const activeLesson = ref(null) const scenario = ref(null) const moduleStage = ref<'lessons' | 'setup' | 'briefing'>('lessons') +const pendingLessonId = ref(null) function displayCallsign(value?: string | null, source?: CallsignContext | null): string { if (!value) return '' @@ -2203,9 +2313,16 @@ function restartCurrentMission() { function startLessonsForCurrent() { if (!current.value) return const mod = current.value - const prog = progress.value[mod.id] || {} - const next = mod.lessons.find(lesson => !(prog[lesson.id]?.done)) || mod.lessons[0] + let next: Lesson | undefined + if (pendingLessonId.value) { + next = mod.lessons.find(lesson => lesson.id === pendingLessonId.value) + } + if (!next) { + const prog = progress.value[mod.id] || {} + next = mod.lessons.find(lesson => !(prog[lesson.id]?.done)) || mod.lessons[0] + } activeLesson.value = next || null + pendingLessonId.value = null } function goToHub() { @@ -3301,6 +3418,16 @@ function moduleSecondaryIcon(modId: string) { return moduleHasProgress(modId) ? 'mdi-view-grid-outline' : 'mdi-information-outline' } +function clearLessonSearch() { + lessonSearch.value = '' +} + +function openLessonFromSearch(modId: string, lesId: string) { + pendingLessonId.value = lesId + lessonSearch.value = '' + openModule(modId, {autoStart: true}) +} + function moduleStatusText(modId: string) { if (!isModuleUnlocked(modId)) return 'Clearance pending' if (moduleCompleted(modId)) return 'Mission complete' @@ -4399,6 +4526,133 @@ onMounted(() => { } +/* HUB search */ +.hub-search { + margin: 18px 0; + display: flex; + flex-direction: column; + gap: 6px; + max-width: 560px; +} + +.hub-search-label { + font-size: 12px; + letter-spacing: .16em; + text-transform: uppercase; + color: var(--t3); +} + +.hub-search-control { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--border); + border-radius: 16px; + padding: 10px 14px; + background: color-mix(in srgb, var(--text) 6%, transparent); + box-shadow: 0 12px 24px rgba(0, 0, 0, .18); +} + +.hub-search-control input { + border: none; + background: transparent; + color: var(--text); + font-size: 15px; + flex: 1; + min-width: 0; +} + +.hub-search-control input:focus { + outline: none; +} + +.hub-search-control input::placeholder { + color: var(--t3); +} + +.hub-search-icon { + color: var(--t3); +} + +.hub-search-clear { + border: none; + background: transparent; + color: var(--t3); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border-radius: 999px; + transition: color .2s ease, background .2s ease; +} + +.hub-search-clear:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); +} + +.hub-search-results { + display: flex; + flex-direction: column; + gap: 10px; + margin: 10px 0 20px; +} + +.search-results-meta { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--t3); +} + +.link.small { + font-size: 12px; + letter-spacing: .12em; + text-transform: uppercase; +} + +.search-results-list { + display: grid; + gap: 10px; +} + +.search-hit { + text-align: left; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: 18px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 0 12px 28px rgba(0, 0, 0, .22); + transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease; +} + +.search-hit:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--accent) 28%, transparent); + box-shadow: 0 18px 36px rgba(0, 0, 0, .28); +} + +.search-hit-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.search-hit-title { + font-weight: 600; + font-size: 16px; + line-height: 1.3; + flex: 1; + min-width: 0; +} + /* HUB tiles */ .hub-head { margin: 6px 0 10px diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts index a7c1be1..33a4a1a 100644 --- a/shared/data/learnModules.ts +++ b/shared/data/learnModules.ts @@ -1,4 +1,4 @@ -import { createBaseScenario, createScenarioSeries, formatTemp } from '~~/shared/learn/scenario' +import { createBaseScenario, createScenarioSeries, digitsToWords, formatTemp, lettersToNato } from '~~/shared/learn/scenario' import type { ModuleDef, Scenario } from '~~/shared/learn/types' function gradientArt(colors: string[]): string { @@ -9,6 +9,72 @@ function gradientArt(colors: string[]): string { return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` } +function randInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function sample(values: readonly T[]): T { + return values[randInt(0, values.length - 1)] +} + +const identifierCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +function randomIdentifier(length: number): string { + let value = '' + for (let i = 0; i < length; i++) { + value += identifierCharacters[randInt(0, identifierCharacters.length - 1)] + } + return value +} + +function toPhoneticBlocks(value: string): string { + return value + .toUpperCase() + .split('') + .map(char => { + if (/[A-Z]/.test(char)) { + return lettersToNato(char) + } + if (/[0-9]/.test(char)) { + return digitsToWords(char) + } + return char + }) + .join(' ') + .replace(/\s+/g, ' ') + .trim() +} + +function createExtendedIdentifierScenario(length = 10): Scenario { + const scenario = createBaseScenario() + const identifier = randomIdentifier(length) + scenario.phoneticCode = identifier + scenario.phoneticCodeWords = toPhoneticBlocks(identifier) + return scenario +} + +const taxiPrefixes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'U', 'V', 'W', 'Y', 'Z'] +const taxiNumbers = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '14', '15', '16'] + +function randomTaxiWaypoint(): string { + const prefix = sample(taxiPrefixes) + const number = sample(taxiNumbers) + return `${prefix}${number}` +} + +function createComplexTaxiScenario(): Scenario { + const scenario = createBaseScenario() + const segments: string[] = [] + const total = randInt(5, 8) + while (segments.length < total) { + const next = randomTaxiWaypoint() + if (segments[segments.length - 1] === next) continue + segments.push(next) + } + scenario.taxiRoute = segments.join(' ') + return scenario +} + const fundamentalsLessons = [ { id: 'icao-alphabet', @@ -57,6 +123,47 @@ const fundamentalsLessons = [ ], generate: createBaseScenario }, + { + id: 'icao-marathon', + title: 'ICAO Marathon Drill', + desc: 'Copy extended identifiers with up to ten characters', + keywords: ['Alphabet', 'Numbers', 'Advanced'], + hints: [ + 'Scan for natural breaks every two to three characters to keep long identifiers manageable.', + 'Say the full sequence back without pauses, including letters and digits exactly as transmitted.' + ], + fields: [ + { + key: 'icao-long-code', + label: 'Identifier', + expected: scenario => scenario.phoneticCode, + alternatives: scenario => { + const value = scenario.phoneticCode + const upper = value.toUpperCase() + const lower = upper.toLowerCase() + const spacedUpper = upper.split('').join(' ') + const spacedLower = lower.split('').join(' ') + const spelled = scenario.phoneticCodeWords + return Array.from( + new Set([upper, lower, spacedUpper, spacedLower, spelled, spelled.toLowerCase()]) + ) + }, + placeholder: 'Enter long identifier', + width: 'xl' + } + ], + readback: [ + { type: 'text', text: 'Identifier ' }, + { type: 'field', key: 'icao-long-code', width: 'xl' } + ], + defaultFrequency: 'DEL', + phrase: scenario => scenario.phoneticCodeWords, + info: scenario => [ + `Heard: ${scenario.phoneticCodeWords}`, + `Identifier: ${scenario.phoneticCode}` + ], + generate: () => createExtendedIdentifierScenario(10) + }, { id: 'radio-call', title: 'Radio Call Sign', @@ -752,6 +859,67 @@ const readbackLessons = [ ], generate: createBaseScenario }, + { + id: 'taxi-complex', + title: 'Complex Taxi Route', + desc: 'Handle long taxi instructions with multiple waypoints', + keywords: ['Ground', 'Taxi', 'Advanced'], + hints: [ + 'Copy the entire route, including every waypoint in the order delivered.', + 'Group waypoints into small chunks so you can read the route back smoothly.' + ], + fields: [ + { + key: 'taxi-complex-runway', + label: 'Runway', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'taxi-complex-route', + label: 'Route', + expected: scenario => scenario.taxiRoute, + alternatives: scenario => [scenario.taxiRoute, `via ${scenario.taxiRoute}`], + width: 'xl' + }, + { + key: 'taxi-complex-hold', + label: 'Hold Short', + expected: scenario => scenario.runway, + alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords], + width: 'sm' + }, + { + key: 'taxi-complex-callsign', + label: 'Callsign', + expected: scenario => scenario.radioCall, + alternatives: scenario => [ + scenario.radioCall, + `${scenario.airlineCall} ${scenario.flightNumber}`, + scenario.callsign + ], + width: 'lg' + } + ], + readback: [ + { type: 'text', text: 'Taxi to runway ' }, + { type: 'field', key: 'taxi-complex-runway', width: 'sm' }, + { type: 'text', text: ' via ' }, + { type: 'field', key: 'taxi-complex-route', width: 'xl' }, + { type: 'text', text: ', holding short runway ' }, + { type: 'field', key: 'taxi-complex-hold', width: 'sm' }, + { type: 'text', text: ', ' }, + { type: 'field', key: 'taxi-complex-callsign', width: 'lg' } + ], + defaultFrequency: 'GND', + phrase: scenario => `${scenario.radioCall}, taxi to runway ${scenario.runway} via ${scenario.taxiRoute}, hold short runway ${scenario.runway}.`, + info: scenario => [ + `Taxi route: ${scenario.taxiRoute}`, + `Hold short: ${scenario.runway}` + ], + generate: createComplexTaxiScenario + }, { id: 'lineup', title: 'Line-up Clearance',