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.
+
(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',