From af804d48b66d143d61ce1f899acabb0a8b4acd48 Mon Sep 17 00:00:00 2001
From: Remi <73385395+itsrubberduck@users.noreply.github.com>
Date: Fri, 19 Sep 2025 16:46:18 +0200
Subject: [PATCH] refactor: externalize learn modules and reduce reactivity
---
shared/data/learnModules.ts | 928 +++++++++++++++++++++++++++++++-----
shared/learn/scenario.ts | 377 +++++++++++++++
shared/learn/types.ts | 141 ++++++
3 files changed, 1319 insertions(+), 127 deletions(-)
create mode 100644 shared/learn/scenario.ts
create mode 100644 shared/learn/types.ts
diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts
index 1d75745..3470928 100644
--- a/shared/data/learnModules.ts
+++ b/shared/data/learnModules.ts
@@ -1,130 +1,804 @@
-import {ref} from "vue";
+import { createBaseScenario, formatTemp } from '~~/shared/learn/scenario'
+import type { ModuleDef } from '~~/shared/learn/types'
-/** MODULES **/
-export type Lesson = { id: string; title: string; desc: string; target: string; hints: string[]; keywords: string[] }
-export type ModuleDef = { id: string; title: string; subtitle: string; art: string; lessons: Lesson[] }
+function gradientArt(colors: string[]): string {
+ const stops = colors
+ .map((color, idx) => ``)
+ .join('')
+ const svg = ``
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
+}
-const modules = ref([
- // New: ICAO chapter
- {
- id: 'icao',
- title: 'ICAO Alphabet',
- subtitle: 'Alphabets & Numbers',
- art: 'https://images.unsplash.com/photo-1488085061387-422e29b40080?q=80&w=1600&auto=format&fit=crop',
- lessons: [
- {
- id: 'alpha',
- title: 'Alphabet A–M',
- desc: 'Say Alpha through Mike.',
- target: 'Alpha Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett Kilo Lima Mike.',
- hints: ['Keep a steady pace', 'Separate each word clearly'],
- keywords: ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel', 'India', 'Juliett', 'Kilo', 'Lima', 'Mike']
- },
- {
- id: 'alpha2',
- title: 'Alphabet N–Z',
- desc: 'November through Zulu.',
- target: 'November Oscar Papa Quebec Romeo Sierra Tango Uniform Victor Whiskey X-ray Yankee Zulu.',
- hints: ['Spell X-ray with a hyphen', "Juliett has two t's"],
- keywords: ['November', 'Oscar', 'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform', 'Victor', 'Whiskey', 'X-ray', 'Yankee', 'Zulu']
- },
- {
- id: 'numbers',
- title: 'Numbers',
- desc: 'Read ICAO numbers.',
- target: 'Tree Fower Fife Six Seven Eight Niner Zero.',
- hints: ['Nine → Niner', 'Three → Tree', 'Four → Fower', 'Five → Fife'],
- keywords: ['Tree', 'Fower', 'Fife', 'Niner']
- },
- {
- id: 'callsign-icao',
- title: 'Spell the call sign',
- desc: 'Sample call sign.',
- target: 'DLH one two three, Lufthansa one two three.',
- hints: ['DLH → Lufthansa', 'Use ICAO numbers'],
- keywords: ['Lufthansa', 'DLH', 'one', 'two', 'three']
- }
- ]
- },
- {
- id: 'basics',
- title: 'Basics',
- subtitle: 'Call sign · Structure · Numbers',
- art: 'https://images.unsplash.com/photo-1541392822270-85b2ff6c4577?q=80&w=1600&auto=format&fit=crop',
- lessons: [
- {
- id: 'checkin',
- title: 'Check-in',
- desc: 'Make the first call correctly.',
- target: 'Frankfurt Ground, Lufthansa one two three at stand A12, request taxi.',
- hints: ['Station • Callsign • Position • Intent'],
- keywords: ['Frankfurt Ground', 'Lufthansa', 'stand', 'request taxi']
- },
- {
- id: 'readback', title: 'Short Readback', desc: 'Give a short acknowledgement.',
- target: 'Lufthansa one two three, roger.',
- hints: ['Callsign + roger/affirm'], keywords: ['roger', 'affirm']
- }
- ]
- },
- {
- id: 'ground',
- title: 'Ground',
- subtitle: 'Taxi • Hold Short • Handoff',
- art: 'https://images.unsplash.com/photo-1523961131990-5ea7c61b2107?q=80&w=1600&auto=format&fit=crop',
- lessons: [
- {
- id: 'taxi1', title: 'Taxi-Clearance', desc: 'Via A, A5, B2.',
- target: 'Lufthansa one two three, taxi to runway two five via A, A five, B two, hold short runway two five.',
- hints: ['Taxi to runway • via • hold short'], keywords: ['taxi to runway', 'via', 'hold short']
- },
- {
- id: 'handoff', title: 'Handoff', desc: 'Frequency change.',
- target: 'Contact Tower on one one niner decimal five, Lufthansa one two three.',
- hints: ['Contact Tower on … • decimal'], keywords: ['Contact Tower', 'decimal']
- }
- ]
- },
- {
- id: 'departure',
- title: 'Departure',
- subtitle: 'Line up • Takeoff',
- art: 'https://images.unsplash.com/photo-1494412685616-a5d310fbb07d?q=80&w=1600&auto=format&fit=crop',
- lessons: [
- {
- id: 'lineup', title: 'Line up', desc: 'Line up and wait.',
- target: 'Lufthansa one two three, line up and wait runway two five.',
- hints: ['line up and wait'], keywords: ['line up and wait']
- }
- ]
- },
- {
- id: 'arrival',
- title: 'Arrival',
- subtitle: 'Approach • Vacate',
- art: 'https://images.unsplash.com/photo-1542089363-07b2d92aacc3?q=80&w=1600&auto=format&fit=crop',
- lessons: [
- {
- id: 'vacate', title: 'Vacate', desc: 'Exit the runway and report clear.',
- target: 'Lufthansa one two three, vacated runway two five via A six.',
- hints: ['vacated runway • via taxiway'], keywords: ['vacated', 'runway']
- }
- ]
- },
- {
- id: 'vatsim',
- title: 'VATSIM',
- subtitle: 'Netiquette • Connect',
- art: 'https://images.unsplash.com/photo-1508264769638-658b34d79f6e?q=80&w=1600&auto=format&fit=crop',
- lessons: [
- {
- id: 'checkin', title: 'IFR Check-in', desc: 'First online call.',
- target: 'Frankfurt Ground, Lufthansa one two three, A320 at stand A12, IFR to Munich, information Bravo, request clearance.',
- hints: ['IFR/VFR • ATIS Info • Request'], keywords: ['IFR', 'information', 'request clearance']
- }
- ]
- }
-])
-
-export default modules
+export const learnModules: ModuleDef[] = [
+ {
+ id: 'normalize',
+ title: 'Normalize · Fundamentals',
+ subtitle: 'Alphabet, ATIS, METAR & Radio Check',
+ art: 'https://images.unsplash.com/photo-1488085061387-422e29b40080?q=80&w=1600&auto=format&fit=crop',
+ lessons: [
+ {
+ id: 'icao-alphabet',
+ title: 'ICAO Alphabet & Numbers',
+ desc: 'Spell letters and digits clearly',
+ keywords: ['Alphabet', 'Numbers', 'Normalize'],
+ hints: [
+ 'Spell each letter with its ICAO name (e.g. Delta, Lima, Hotel).',
+ 'Use ATC numbers: tree, fower, fife, niner.'
+ ],
+ fields: [
+ {
+ key: 'letters',
+ label: 'Letters',
+ expected: scenario => scenario.callsignNato,
+ placeholder: 'Delta Lima Hotel',
+ width: 'xl',
+ threshold: 0.9
+ },
+ {
+ key: 'digits',
+ label: 'Numbers',
+ expected: scenario => scenario.flightNumberWords,
+ placeholder: 'one two three',
+ width: 'lg',
+ threshold: 0.88
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Call sign: ' },
+ { type: 'field', key: 'letters', width: 'lg' },
+ { type: 'text', text: ' · ' },
+ { type: 'field', key: 'digits', width: 'md' }
+ ],
+ defaultFrequency: 'DEL',
+ phrase: scenario => `${scenario.callsignNato} ${scenario.flightNumberWords}`,
+ info: scenario => [
+ `Callsign: ${scenario.callsign}`,
+ `Radio call: ${scenario.radioCall}`,
+ `ICAO spelling: ${scenario.callsignNato}`,
+ `Flight number spoken: ${scenario.flightNumberWords}`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'atis',
+ title: 'Understand the ATIS',
+ desc: 'Extract the identifier and key data from the ATIS',
+ keywords: ['ATIS', 'Weather'],
+ hints: [
+ 'Remember the ATIS identifier as a single letter.',
+ 'Order: runway – wind – visibility – temperature – dew point – QNH.'
+ ],
+ fields: [
+ {
+ key: 'atis-code',
+ label: 'ATIS',
+ expected: scenario => scenario.atisCode,
+ alternatives: scenario => [
+ scenario.atisCode,
+ scenario.atisCode.toLowerCase(),
+ `Information ${scenario.atisCode}`
+ ],
+ placeholder: 'Letter',
+ width: 'xs',
+ threshold: 0.9
+ },
+ {
+ key: 'atis-runway',
+ label: 'Runway',
+ expected: scenario => scenario.runway,
+ alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
+ width: 'sm'
+ },
+ {
+ key: 'atis-wind',
+ label: 'Wind',
+ expected: scenario => scenario.wind,
+ alternatives: scenario => [
+ scenario.wind,
+ `${scenario.wind}KT`,
+ scenario.windWords
+ ],
+ width: 'md'
+ },
+ {
+ key: 'atis-visibility',
+ label: 'Visibility',
+ expected: scenario => scenario.atisSummary.visibility,
+ alternatives: scenario => [scenario.visibility, scenario.visibilityWords],
+ width: 'sm'
+ },
+ {
+ key: 'atis-temp',
+ label: 'Temperature',
+ expected: scenario => scenario.atisSummary.temperature,
+ alternatives: scenario => [scenario.temperatureWords],
+ width: 'sm'
+ },
+ {
+ key: 'atis-dew',
+ label: 'Dew point',
+ expected: scenario => scenario.atisSummary.dewpoint,
+ alternatives: scenario => [scenario.dewpointWords],
+ width: 'sm'
+ },
+ {
+ key: 'atis-qnh',
+ label: 'QNH',
+ expected: scenario => scenario.qnh.toString(),
+ alternatives: scenario => [`QNH ${scenario.qnh}`, scenario.qnhWords],
+ width: 'sm'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Information ' },
+ { type: 'field', key: 'atis-code', width: 'xs' },
+ { type: 'text', text: ', runway ' },
+ { type: 'field', key: 'atis-runway', width: 'sm' },
+ { type: 'text', text: ', wind ' },
+ { type: 'field', key: 'atis-wind', width: 'md' },
+ { type: 'text', text: ', visibility ' },
+ { type: 'field', key: 'atis-visibility', width: 'sm' },
+ { type: 'text', text: ', temperature ' },
+ { type: 'field', key: 'atis-temp', width: 'sm' },
+ { type: 'text', text: ', dew point ' },
+ { type: 'field', key: 'atis-dew', width: 'sm' },
+ { type: 'text', text: ', QNH ' },
+ { type: 'field', key: 'atis-qnh', width: 'sm' }
+ ],
+ defaultFrequency: 'ATIS',
+ phrase: scenario => scenario.atisText,
+ info: () => [
+ 'Note the identifier, runway, wind, visibility, temperature, dew point, and QNH.',
+ 'Visibility: four digits or 9999 for ≥10 km; QNH as a four-digit value.'
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'metar',
+ title: 'Decode the METAR',
+ desc: 'Extract the raw METAR values',
+ keywords: ['METAR', 'Weather'],
+ hints: [
+ 'Read the METAR in blocks: wind – visibility – clouds – temperature – QNH.',
+ 'The temperature block looks like 18/10; negative values start with M.'
+ ],
+ fields: [
+ {
+ key: 'metar-wind',
+ label: 'Wind',
+ expected: scenario => scenario.metarSegments.wind,
+ alternatives: scenario => [
+ scenario.metarSegments.wind,
+ `${scenario.wind.replace('/', '')}KT`,
+ scenario.wind
+ ],
+ width: 'md'
+ },
+ {
+ key: 'metar-vis',
+ label: 'Visibility',
+ expected: scenario => scenario.metarSegments.visibility,
+ alternatives: scenario => [scenario.visibility],
+ placeholder: '9999',
+ width: 'sm',
+ inputmode: 'numeric'
+ },
+ {
+ key: 'metar-temp',
+ label: 'Temp/Dew',
+ expected: scenario => scenario.metarSegments.temp,
+ alternatives: scenario => [
+ `${formatTemp(scenario.temperature)}/${formatTemp(scenario.dewpoint)}`
+ ],
+ width: 'md'
+ },
+ {
+ key: 'metar-qnh',
+ label: 'QNH',
+ expected: scenario => scenario.metarSegments.qnh,
+ alternatives: scenario => [`Q${scenario.qnh}`, scenario.qnh.toString()],
+ width: 'sm'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Wind ' },
+ { type: 'field', key: 'metar-wind', width: 'md' },
+ { type: 'text', text: ', visibility ' },
+ { type: 'field', key: 'metar-vis', width: 'sm' },
+ { type: 'text', text: ', temperature ' },
+ { type: 'field', key: 'metar-temp', width: 'md' },
+ { type: 'text', text: ', QNH ' },
+ { type: 'field', key: 'metar-qnh', width: 'sm' }
+ ],
+ defaultFrequency: 'ATIS',
+ phrase: scenario => scenario.metar,
+ info: scenario => [
+ `METAR: ${scenario.metar}`,
+ `Interpretation: Wind ${scenario.metarSegments.wind}, visibility ${scenario.visibilityWords}, temperature ${scenario.temperature}°C, QNH ${scenario.qnh}`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'radio-check',
+ title: 'Radio Check',
+ desc: 'Report readability',
+ keywords: ['Ground', 'Comms'],
+ hints: [
+ 'Reply with "Readability" followed by the number.',
+ 'Always finish the check with your call sign.'
+ ],
+ fields: [
+ {
+ key: 'rc-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: scenario => [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ],
+ placeholder: 'Lufthansa one two three',
+ width: 'lg'
+ },
+ {
+ key: 'rc-readability',
+ label: 'Readability',
+ expected: scenario => scenario.readabilityWord,
+ alternatives: scenario => [scenario.readability.toString(), scenario.readabilityWord],
+ placeholder: 'five',
+ width: 'sm'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'This is ' },
+ { type: 'field', key: 'rc-callsign', width: 'lg' },
+ { type: 'text', text: ', readability ' },
+ { type: 'field', key: 'rc-readability', width: 'sm' }
+ ],
+ defaultFrequency: 'GND',
+ phrase: scenario => `${scenario.airport.name} Ground, ${scenario.radioCall}, radio check on ${scenario.groundFreq}.`,
+ info: scenario => [
+ `Frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
+ `Expected response: Readability ${scenario.readability} (${scenario.readabilityWord})`
+ ],
+ generate: createBaseScenario
+ }
+ ]
+ },
+ {
+ id: 'arc',
+ title: 'ARC Decision Tree',
+ subtitle: 'From clearance call to departure',
+ art: gradientArt(['#f97316', '#fb923c', '#0f172a']),
+ lessons: [
+ {
+ id: 'clearance-contact',
+ title: 'Delivery: Initial contact',
+ desc: 'Contact clearance delivery',
+ keywords: ['Delivery', 'Clearance'],
+ hints: [
+ 'Order: unit, call sign, ATIS, destination, stand, request.',
+ 'Say the ATIS as a single letter.'
+ ],
+ fields: [
+ {
+ key: 'cd-atis',
+ label: 'ATIS',
+ expected: scenario => scenario.atisCode,
+ alternatives: scenario => [
+ scenario.atisCode,
+ scenario.atisCode.toLowerCase(),
+ `Information ${scenario.atisCode}`
+ ],
+ width: 'xs',
+ threshold: 0.9
+ },
+ {
+ key: 'cd-dest',
+ label: 'Destination',
+ expected: scenario => scenario.destination.city,
+ alternatives: scenario => [scenario.destination.icao, scenario.destination.name],
+ width: 'md'
+ },
+ {
+ key: 'cd-stand',
+ label: 'Stand/Gate',
+ expected: scenario => scenario.stand,
+ width: 'sm'
+ }
+ ],
+ readback: [
+ {
+ type: 'text',
+ text: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information `
+ },
+ { type: 'field', key: 'cd-atis', width: 'xs' },
+ { type: 'text', text: ', IFR to ' },
+ { type: 'field', key: 'cd-dest', width: 'md' },
+ { type: 'text', text: ', stand ' },
+ { type: 'field', key: 'cd-stand', width: 'sm' },
+ { type: 'text', text: ', request clearance.' }
+ ],
+ defaultFrequency: 'DEL',
+ phrase: scenario => `${scenario.airport.city} Delivery, ${scenario.radioCall}, information ${scenario.atisCode}, IFR to ${scenario.destination.city}, stand ${scenario.stand}, request clearance.`,
+ info: scenario => [
+ `ATIS: ${scenario.atisCode}`,
+ `Destination: ${scenario.destination.city} (${scenario.destination.icao})`,
+ `Stand/Gate: ${scenario.stand}`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'clearance-readback',
+ title: 'Clearance Readback',
+ desc: 'Read back the clearance in full',
+ keywords: ['Delivery', 'Readback'],
+ hints: [
+ 'Remember the order: destination – SID – runway – altitude – squawk.',
+ 'Speak altitude and squawk digits clearly.'
+ ],
+ fields: [
+ {
+ key: 'clr-dest',
+ label: 'Destination',
+ expected: scenario => scenario.destination.city,
+ alternatives: scenario => [scenario.destination.icao, scenario.destination.name],
+ width: 'md'
+ },
+ {
+ key: 'clr-sid',
+ label: 'SID',
+ expected: scenario => scenario.sid,
+ width: 'lg'
+ },
+ {
+ key: 'clr-runway',
+ label: 'Runway',
+ expected: scenario => scenario.runway,
+ alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
+ width: 'sm'
+ },
+ {
+ key: 'clr-alt',
+ label: 'Initial Altitude',
+ expected: scenario => scenario.altitudes.initialWords,
+ alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
+ width: 'md'
+ },
+ {
+ key: 'clr-squawk',
+ label: 'Squawk',
+ expected: scenario => scenario.squawkWords,
+ alternatives: scenario => [scenario.squawk, scenario.squawk.split('').join(' ')],
+ width: 'md'
+ }
+ ],
+ readback: [
+ { type: 'text', text: scenario => `${scenario.radioCall} cleared ` },
+ { type: 'field', key: 'clr-dest', width: 'md' },
+ { type: 'text', text: ' via ' },
+ { type: 'field', key: 'clr-sid', width: 'lg' },
+ { type: 'text', text: ', runway ' },
+ { type: 'field', key: 'clr-runway', width: 'sm' },
+ { type: 'text', text: ', climb ' },
+ { type: 'field', key: 'clr-alt', width: 'md' },
+ { type: 'text', text: ', squawk ' },
+ { type: 'field', key: 'clr-squawk', width: 'md' }
+ ],
+ defaultFrequency: 'DEL',
+ phrase: scenario => `${scenario.radioCall}, cleared to ${scenario.destination.city} via ${scenario.sid}, runway ${scenario.runway}, climb ${scenario.altitudes.initial} feet, squawk ${scenario.squawk}.`,
+ info: scenario => [
+ `SID: ${scenario.sid} (${scenario.transition})`,
+ `Initial Altitude: ${scenario.altitudes.initial} ft (${scenario.altitudes.initialWords})`,
+ `Squawk: ${scenario.squawk} (${scenario.squawkWords})`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'pushback',
+ title: 'Push & Start Readback',
+ desc: 'Acknowledge the pushback clearance',
+ keywords: ['Ground', 'Pushback'],
+ hints: [
+ 'Repeat the runway direction and QNH, call sign at the end.',
+ 'QNH may be just the number or "QNH xxxx".'
+ ],
+ fields: [
+ {
+ key: 'push-runway',
+ label: 'Runway',
+ expected: scenario => scenario.runway,
+ alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
+ width: 'sm'
+ },
+ {
+ key: 'push-qnh',
+ label: 'QNH',
+ expected: scenario => scenario.qnh.toString(),
+ alternatives: scenario => [`QNH ${scenario.qnh}`, scenario.qnhWords],
+ width: 'sm'
+ },
+ {
+ key: 'push-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: scenario => [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ],
+ width: 'lg'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Push and start approved, facing runway ' },
+ { type: 'field', key: 'push-runway', width: 'sm' },
+ { type: 'text', text: ', QNH ' },
+ { type: 'field', key: 'push-qnh', width: 'sm' },
+ { type: 'text', text: ', ' },
+ { type: 'field', key: 'push-callsign', width: 'lg' }
+ ],
+ defaultFrequency: 'GND',
+ phrase: scenario => `${scenario.radioCall}, push and start approved, facing runway ${scenario.runway}. QNH ${scenario.qnh}.`,
+ info: scenario => [
+ `Stand/Gate: ${scenario.stand}`,
+ `Ground: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'taxi',
+ title: 'Taxi Readback',
+ desc: 'Read back the taxi clearance with hold short',
+ keywords: ['Ground', 'Taxi'],
+ hints: [
+ 'Repeat the route exactly as received, including the hold short.',
+ 'Finish with the call sign.'
+ ],
+ fields: [
+ {
+ key: 'taxi-runway',
+ label: 'Runway',
+ expected: scenario => scenario.runway,
+ alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
+ width: 'sm'
+ },
+ {
+ key: 'taxi-route',
+ label: 'Route',
+ expected: scenario => scenario.taxiRoute,
+ alternatives: scenario => [scenario.taxiRoute, `via ${scenario.taxiRoute}`],
+ width: 'lg'
+ },
+ {
+ key: 'taxi-hold',
+ label: 'Hold Short',
+ expected: scenario => scenario.runway,
+ alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
+ width: 'sm'
+ },
+ {
+ key: 'taxi-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-runway', width: 'sm' },
+ { type: 'text', text: ' via ' },
+ { type: 'field', key: 'taxi-route', width: 'lg' },
+ { type: 'text', text: ', holding short runway ' },
+ { type: 'field', key: 'taxi-hold', width: 'sm' },
+ { type: 'text', text: ', ' },
+ { type: 'field', key: 'taxi-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: createBaseScenario
+ },
+ {
+ id: 'lineup',
+ title: 'Line-up Clearance',
+ desc: 'Acknowledge line up and wait',
+ keywords: ['Tower', 'Line Up'],
+ hints: [
+ 'Repeat the runway, then say "line up and wait".',
+ 'Place the call sign at the end.'
+ ],
+ fields: [
+ {
+ key: 'lineup-runway',
+ label: 'Runway',
+ expected: scenario => scenario.runway,
+ alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
+ width: 'sm'
+ },
+ {
+ key: 'lineup-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: scenario => [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ],
+ width: 'lg'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Runway ' },
+ { type: 'field', key: 'lineup-runway', width: 'sm' },
+ { type: 'text', text: ', line up and wait, ' },
+ { type: 'field', key: 'lineup-callsign', width: 'lg' }
+ ],
+ defaultFrequency: 'TWR',
+ phrase: scenario => `${scenario.radioCall}, line up and wait runway ${scenario.runway}.`,
+ info: scenario => [
+ `Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`,
+ `Line-up: runway ${scenario.runway}`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'takeoff',
+ title: 'Takeoff Clearance',
+ desc: 'Acknowledge the takeoff clearance',
+ keywords: ['Tower', 'Departure'],
+ hints: [
+ 'Order: runway – cleared for takeoff – call sign.',
+ 'Wind information can be omitted if it was not given.'
+ ],
+ fields: [
+ {
+ key: 'tko-runway',
+ label: 'Runway',
+ expected: scenario => scenario.runway,
+ alternatives: scenario => [scenario.runway.replace(/^0/, ''), scenario.runwayWords],
+ width: 'sm'
+ },
+ {
+ key: 'tko-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: scenario => [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ],
+ width: 'lg'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Runway ' },
+ { type: 'field', key: 'tko-runway', width: 'sm' },
+ { type: 'text', text: ', cleared for takeoff, ' },
+ { type: 'field', key: 'tko-callsign', width: 'lg' }
+ ],
+ defaultFrequency: 'TWR',
+ phrase: scenario => `${scenario.radioCall}, wind ${scenario.windWords}, runway ${scenario.runway}, cleared for takeoff.`,
+ info: scenario => [
+ `Tower: ${scenario.towerFreq} (${scenario.frequencyWords.TWR})`,
+ `Wind: ${scenario.wind} (${scenario.windWords})`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'departure-handoff',
+ title: 'Departure Handoff',
+ desc: 'Switch to departure',
+ keywords: ['Departure', 'Handoff'],
+ hints: [
+ 'Repeat the frequency exactly, either as digits or spoken form.',
+ 'Append the call sign at the end.'
+ ],
+ fields: [
+ {
+ key: 'dep-freq',
+ label: 'Frequenz',
+ expected: scenario => scenario.departureFreq,
+ alternatives: scenario => [
+ scenario.departureFreq,
+ scenario.departureFreq.replace('.', ''),
+ scenario.frequencyWords.DEP
+ ],
+ width: 'md'
+ },
+ {
+ key: 'dep-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: scenario => [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ],
+ width: 'lg'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Contact departure ' },
+ { type: 'field', key: 'dep-freq', width: 'md' },
+ { type: 'text', text: ', ' },
+ { type: 'field', key: 'dep-callsign', width: 'lg' }
+ ],
+ defaultFrequency: 'DEP',
+ phrase: scenario => `${scenario.radioCall}, contact departure ${scenario.departureFreq}.`,
+ info: scenario => [
+ `Departure: ${scenario.departureFreq} (${scenario.frequencyWords.DEP})`,
+ `Tower handoff after departure.`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'departure-checkin',
+ title: 'Departure Check-in',
+ desc: 'Initial call to departure',
+ keywords: ['Departure', 'Check-in'],
+ hints: [
+ 'Address the unit first, then state the call sign.',
+ 'Repeat the altitude and SID exactly as received.'
+ ],
+ fields: [
+ {
+ key: 'depcheck-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: scenario => [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ],
+ width: 'lg'
+ },
+ {
+ key: 'depcheck-alt',
+ label: 'Altitude',
+ expected: scenario => scenario.altitudes.initialWords,
+ alternatives: scenario => [
+ scenario.altitudes.initialWords,
+ scenario.altitudes.initial.toString(),
+ `${scenario.altitudes.initial} feet`
+ ],
+ width: 'md'
+ },
+ {
+ key: 'depcheck-sid',
+ label: 'SID',
+ expected: scenario => scenario.sid,
+ width: 'lg'
+ }
+ ],
+ readback: [
+ { type: 'text', text: scenario => `${scenario.airport.city} Departure, ` },
+ { type: 'field', key: 'depcheck-callsign', width: 'lg' },
+ { type: 'text', text: ', passing ' },
+ { type: 'field', key: 'depcheck-alt', width: 'md' },
+ { type: 'text', text: ', on SID ' },
+ { type: 'field', key: 'depcheck-sid', width: 'lg' }
+ ],
+ defaultFrequency: 'DEP',
+ phrase: scenario => `${scenario.airport.city} Departure, ${scenario.radioCall}, passing ${scenario.altitudes.initialWords}, on SID ${scenario.sid}.`,
+ info: scenario => [
+ `Initial Altitude: ${scenario.altitudes.initial} ft (${scenario.altitudes.initialWords})`,
+ `SID: ${scenario.sid}`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'climb-instruction',
+ title: 'Climb & Direct',
+ desc: 'Read back the climb instruction in full',
+ keywords: ['Departure', 'Climb'],
+ hints: [
+ 'Start with "Climb", then the altitude and any direct clearance.',
+ 'Repeat the call sign at the end.'
+ ],
+ fields: [
+ {
+ key: 'climb-alt',
+ label: 'Altitude',
+ expected: scenario => scenario.altitudes.climbWords,
+ alternatives: scenario => [
+ scenario.altitudes.climbWords,
+ scenario.altitudes.climb.toString(),
+ `${scenario.altitudes.climb} feet`
+ ],
+ width: 'md'
+ },
+ {
+ key: 'climb-direct',
+ label: 'Direct',
+ expected: scenario => scenario.transition,
+ width: 'md'
+ },
+ {
+ key: 'climb-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: scenario => [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ],
+ width: 'lg'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Climb ' },
+ { type: 'field', key: 'climb-alt', width: 'md' },
+ { type: 'text', text: ', direct ' },
+ { type: 'field', key: 'climb-direct', width: 'md' },
+ { type: 'text', text: ', ' },
+ { type: 'field', key: 'climb-callsign', width: 'lg' }
+ ],
+ defaultFrequency: 'DEP',
+ phrase: scenario => `${scenario.radioCall}, climb ${scenario.altitudes.climb} feet, proceed direct ${scenario.transition}.`,
+ info: scenario => [
+ `Climb: ${scenario.altitudes.climb} ft (${scenario.altitudes.climbWords})`,
+ `Direct to: ${scenario.transition}`
+ ],
+ generate: createBaseScenario
+ },
+ {
+ id: 'center-handoff',
+ title: 'Center Handoff',
+ desc: 'Acknowledge the handoff to center',
+ keywords: ['Center', 'Handoff'],
+ hints: [
+ 'Repeat the frequency exactly (with or without the decimal point).',
+ 'Place the call sign at the end.'
+ ],
+ fields: [
+ {
+ key: 'ctr-freq',
+ label: 'Frequenz',
+ expected: scenario => scenario.centerFreq,
+ alternatives: scenario => [
+ scenario.centerFreq,
+ scenario.centerFreq.replace('.', ''),
+ scenario.frequencyWords.CTR
+ ],
+ width: 'md'
+ },
+ {
+ key: 'ctr-callsign',
+ label: 'Callsign',
+ expected: scenario => scenario.radioCall,
+ alternatives: scenario => [
+ scenario.radioCall,
+ `${scenario.airlineCall} ${scenario.flightNumber}`,
+ scenario.callsign
+ ],
+ width: 'lg'
+ }
+ ],
+ readback: [
+ { type: 'text', text: 'Contact center ' },
+ { type: 'field', key: 'ctr-freq', width: 'md' },
+ { type: 'text', text: ', ' },
+ { type: 'field', key: 'ctr-callsign', width: 'lg' }
+ ],
+ defaultFrequency: 'CTR',
+ phrase: scenario => `${scenario.radioCall}, contact center ${scenario.centerFreq}.`,
+ info: scenario => [
+ `Center: ${scenario.centerFreq} (${scenario.frequencyWords.CTR})`,
+ `Next unit: Center`
+ ],
+ generate: createBaseScenario
+ }
+ ]
+ }
+]
+export default learnModules
diff --git a/shared/learn/scenario.ts b/shared/learn/scenario.ts
new file mode 100644
index 0000000..877ab4e
--- /dev/null
+++ b/shared/learn/scenario.ts
@@ -0,0 +1,377 @@
+import type { AirlineData, AirportData, Frequency, FrequencyType, Scenario } from './types'
+
+const natoMap: Record = {
+ A: 'Alpha',
+ B: 'Bravo',
+ C: 'Charlie',
+ D: 'Delta',
+ E: 'Echo',
+ F: 'Foxtrot',
+ G: 'Golf',
+ H: 'Hotel',
+ I: 'India',
+ J: 'Juliett',
+ K: 'Kilo',
+ L: 'Lima',
+ M: 'Mike',
+ N: 'November',
+ O: 'Oscar',
+ P: 'Papa',
+ Q: 'Quebec',
+ R: 'Romeo',
+ S: 'Sierra',
+ T: 'Tango',
+ U: 'Uniform',
+ V: 'Victor',
+ W: 'Whiskey',
+ X: 'X-ray',
+ Y: 'Yankee',
+ Z: 'Zulu'
+}
+
+const atcNumberWords: Record = {
+ '0': 'zero',
+ '1': 'one',
+ '2': 'two',
+ '3': 'tree',
+ '4': 'four',
+ '5': 'fife',
+ '6': 'six',
+ '7': 'seven',
+ '8': 'eight',
+ '9': 'niner'
+}
+
+const runwaySuffixWords: Record = {
+ L: 'left',
+ R: 'right',
+ C: 'center'
+}
+
+const readabilityScale = [
+ { level: 1, word: 'one', description: 'Unreadable' },
+ { level: 2, word: 'two', description: 'Barely readable' },
+ { level: 3, word: 'three', description: 'Readable with difficulty' },
+ { level: 4, word: 'four', description: 'Readable' },
+ { level: 5, word: 'five', description: 'Perfectly readable' }
+]
+
+const airlines: AirlineData[] = [
+ { code: 'DLH', call: 'Lufthansa' },
+ { code: 'BAW', call: 'Speedbird' },
+ { code: 'AFR', call: 'Air France' },
+ { code: 'KLM', call: 'KLM' },
+ { code: 'SWR', call: 'Swiss' },
+ { code: 'EZY', call: 'Easyjet' }
+]
+
+const airportsData: AirportData[] = [
+ {
+ icao: 'EDDF',
+ name: 'Frankfurt/Main',
+ city: 'Frankfurt',
+ runways: ['25C', '25R', '07C', '07R', '18'],
+ stands: ['V155', 'A12', 'B24', 'G5', 'H43', 'L21'],
+ sids: ['ANEKI 7S', 'TOBAK 5Q', 'OBOKA 6N'],
+ transitions: ['ANEKI', 'TOBAK', 'OBOKA'],
+ approaches: ['ILS Z 25C', 'ILS Y 07C'],
+ taxi: ['N3 U4', 'V A', 'N7 K', 'S V12'],
+ freqs: {
+ atis: '126.350',
+ delivery: '121.900',
+ ground: '121.800',
+ tower: '118.700',
+ departure: '125.350',
+ approach: '120.800',
+ center: '134.200'
+ },
+ transLevel: 'FL070'
+ },
+ {
+ icao: 'EDDM',
+ name: 'Munich',
+ city: 'Munich',
+ runways: ['26R', '26L', '08R', '08L'],
+ stands: ['211', '214', '302', 'N16', 'H45'],
+ sids: ['OBAXA 3S', 'MERSI 6S', 'TULSI 5M'],
+ transitions: ['OBAXA', 'MERSI', 'TULSI'],
+ approaches: ['ILS Z 26R', 'ILS Y 08R'],
+ taxi: ['L4 N3', 'P3 W2', 'S1 D2'],
+ freqs: {
+ atis: '122.130',
+ delivery: '121.775',
+ ground: '121.800',
+ tower: '118.700',
+ departure: '129.050',
+ approach: '120.800',
+ center: '133.700'
+ },
+ transLevel: 'FL070'
+ },
+ {
+ icao: 'EHAM',
+ name: 'Amsterdam Schiphol',
+ city: 'Amsterdam',
+ runways: ['24', '36L', '18C', '09'],
+ stands: ['D14', 'E22', 'F8', 'H4'],
+ sids: ['SUGOL 2S', 'ANDIK 2S', 'ARNEM 2V'],
+ transitions: ['SUGOL', 'ANDIK', 'ARNEM'],
+ approaches: ['ILS Z 24', 'ILS Y 18C'],
+ taxi: ['A5 B2', 'K1 V3', 'W4 S8'],
+ freqs: {
+ atis: '136.050',
+ delivery: '121.800',
+ ground: '121.900',
+ tower: '119.220',
+ departure: '123.875',
+ approach: '121.200',
+ center: '135.050'
+ },
+ transLevel: 'FL060'
+ }
+]
+
+const atisLetters = 'ABCDEFGHJKLMNOPQRSTUVWXYZ'.split('')
+
+function choice(items: T[]): T {
+ return items[Math.floor(Math.random() * items.length)]
+}
+
+function randInt(min: number, max: number): number {
+ return Math.floor(Math.random() * (max - min + 1)) + min
+}
+
+function randomFlightNumber(): string {
+ const length = Math.random() < 0.5 ? 3 : 4
+ let value = ''
+ for (let i = 0; i < length; i++) {
+ value += String(randInt(0, 9))
+ }
+ if (value.startsWith('0')) value = '1' + value.slice(1)
+ return value
+}
+
+function generateSquawk(): string {
+ let code = ''
+ for (let i = 0; i < 4; i++) {
+ code += String(randInt(0, 7))
+ }
+ return code
+}
+
+function digitsToWords(value: string): string {
+ return value
+ .split('')
+ .map(char => atcNumberWords[char] ?? char)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+function lettersToNato(value: string): string {
+ return value
+ .toUpperCase()
+ .split('')
+ .map(char => natoMap[char] ?? char)
+ .join(' ')
+}
+
+function runwayToWords(runway: string): string {
+ const digits = runway.replace(/[^0-9]/g, '').padStart(2, '0')
+ const suffix = runway.replace(/[0-9]/g, '')
+ const base = digits
+ .split('')
+ .map(char => atcNumberWords[char] ?? char)
+ .join(' ')
+ const suffixWord = suffix ? runwaySuffixWords[suffix] ?? suffix.toLowerCase() : ''
+ return suffixWord ? `${base} ${suffixWord}` : base
+}
+
+function frequencyToSpeech(freq: string): string {
+ const [intPart, decimalPart] = freq.split('.')
+ const intWords = digitsToWords(intPart)
+ if (!decimalPart) return intWords
+ const trimmed = decimalPart.replace(/0+$/, '')
+ const decWords = trimmed ? digitsToWords(trimmed) : 'zero'
+ return `${intWords} decimal ${decWords}`
+}
+
+export function formatTemp(temp: number): string {
+ const prefix = temp < 0 ? 'M' : ''
+ return `${prefix}${Math.abs(temp).toString().padStart(2, '0')}`
+}
+
+function temperatureToWords(temp: number): string {
+ const prefix = temp < 0 ? 'minus' : 'plus'
+ return `${prefix} ${digitsToWords(Math.abs(temp).toString())}`
+}
+
+function qnhToWords(qnh: number): string {
+ return digitsToWords(qnh.toString())
+}
+
+function windToWords(direction: number, speed: number): string {
+ const dir = direction.toString().padStart(3, '0')
+ const spd = speed.toString().padStart(2, '0')
+ return `${dir
+ .split('')
+ .map(char => atcNumberWords[char] ?? char)
+ .join(' ')} degrees at ${spd
+ .split('')
+ .map(char => atcNumberWords[char] ?? char)
+ .join(' ')} knots`
+}
+
+function visibilityToWords(vis: string): string {
+ if (vis === '9999') return 'ten kilometres or more'
+ const numeric = Number(vis)
+ if (!Number.isNaN(numeric)) {
+ if (numeric >= 1000) {
+ const km = Math.round(numeric / 1000)
+ return `${digitsToWords(km.toString())} kilometres`
+ }
+ return `${digitsToWords(vis)} metres`
+ }
+ return vis
+}
+
+function altitudeToWords(value: number): string {
+ const thousands = Math.floor(value / 1000)
+ const remainder = value % 1000
+ let words = thousands ? `${digitsToWords(thousands.toString())} thousand` : ''
+ if (remainder) {
+ words = `${words} ${digitsToWords(remainder.toString())}`.trim()
+ }
+ return words.trim()
+}
+
+export function createBaseScenario(): Scenario {
+ const airport = choice(airportsData)
+ const possibleDestinations = airportsData.filter(a => a.icao !== airport.icao)
+ const destination = choice(possibleDestinations)
+ const airline = choice(airlines)
+ const flightNumber = randomFlightNumber()
+ const callsign = `${airline.code}${flightNumber}`
+ const runway = choice(airport.runways)
+ const stand = choice(airport.stands)
+ const taxiRoute = choice(airport.taxi)
+ const sid = choice(airport.sids)
+ const transition = choice(airport.transitions)
+ const approach = choice(airport.approaches)
+ const altitude = choice([4000, 5000, 6000, 7000])
+ const climbAltitude = altitude + 2000
+ const squawk = generateSquawk()
+ const qnh = randInt(984, 1032)
+ const windDirection = randInt(0, 35) * 10
+ const windSpeed = randInt(3, 18)
+ const windDirectionStr = windDirection.toString().padStart(3, '0')
+ const windSpeedStr = windSpeed.toString().padStart(2, '0')
+ const visibility = choice(['9999', '9000', '8000', '6000'])
+ const cloud = choice(['SKC', 'FEW020', 'SCT025', 'BKN030'])
+ const temperature = randInt(-3, 28)
+ const dewpoint = Math.max(temperature - randInt(2, 6), -10)
+ const atisCode = choice(atisLetters)
+ const remarks = choice(['NOSIG', 'BECMG 4000', 'TEMPO -SHRA'])
+ const now = new Date()
+ const minute = Math.floor(now.getUTCMinutes() / 5) * 5
+ const timestamp = `${now.getUTCDate().toString().padStart(2, '0')}${now
+ .getUTCHours()
+ .toString()
+ .padStart(2, '0')}${minute.toString().padStart(2, '0')}Z`
+ const metarWindGroup = `${windDirectionStr}${windSpeedStr}KT`
+ const tempGroup = `${formatTemp(temperature)}/${formatTemp(dewpoint)}`
+ const metar = `${airport.icao} ${timestamp} ${metarWindGroup} ${visibility} ${cloud} ${tempGroup} Q${qnh
+ .toString()
+ .padStart(4, '0')} ${remarks}`
+
+ const frequencies: Frequency[] = [
+ { type: 'ATIS', label: 'ATIS', value: airport.freqs.atis },
+ { type: 'DEL', label: 'Delivery', value: airport.freqs.delivery },
+ { type: 'GND', label: 'Ground', value: airport.freqs.ground },
+ { type: 'TWR', label: 'Tower', value: airport.freqs.tower },
+ { type: 'DEP', label: 'Departure', value: airport.freqs.departure },
+ { type: 'APP', label: 'Approach', value: airport.freqs.approach },
+ { type: 'CTR', label: 'Center', value: airport.freqs.center }
+ ]
+
+ const frequencyWords = frequencies.reduce((acc, freq) => {
+ acc[freq.type] = frequencyToSpeech(freq.value)
+ return acc
+ }, {} as Record)
+
+ const readability = choice(readabilityScale)
+
+ const atisText = `${airport.name} information ${atisCode}, time ${timestamp.slice(2, 4)}${timestamp.slice(4, 6)}, runway ${
+ runwayToWords(runway)
+ } in use, wind ${windToWords(windDirection, windSpeed)}, visibility ${visibilityToWords(visibility)}, temperature ${
+ temperatureToWords(temperature)
+ }, dew point ${temperatureToWords(dewpoint)}, QNH ${qnh}, transition level ${airport.transLevel.replace('FL', '')}.`
+
+ return {
+ callsign,
+ airlineCode: airline.code,
+ airlineCall: airline.call,
+ radioCall: `${airline.call} ${digitsToWords(flightNumber)}`,
+ callsignNato: lettersToNato(airline.code),
+ flightNumber,
+ flightNumberWords: digitsToWords(flightNumber),
+ airport,
+ destination,
+ runway,
+ runwayWords: runwayToWords(runway),
+ stand,
+ taxiRoute,
+ sid,
+ transition,
+ approach,
+ altitudes: {
+ initial: altitude,
+ climb: climbAltitude,
+ initialWords: altitudeToWords(altitude),
+ climbWords: altitudeToWords(climbAltitude)
+ },
+ squawk,
+ squawkWords: digitsToWords(squawk),
+ qnh,
+ qnhWords: qnhToWords(qnh),
+ atisCode,
+ atisText,
+ atisSummary: {
+ runway,
+ wind: `${windDirectionStr}/${windSpeedStr}`,
+ visibility,
+ temperature: `${temperature}°C`,
+ dewpoint: `${dewpoint}°C`,
+ qnh: `QNH ${qnh}`
+ },
+ wind: `${windDirectionStr}/${windSpeedStr}`,
+ windWords: windToWords(windDirection, windSpeed),
+ visibility,
+ visibilityWords: visibilityToWords(visibility),
+ temperature,
+ temperatureWords: temperatureToWords(temperature),
+ dewpoint,
+ dewpointWords: temperatureToWords(dewpoint),
+ metar,
+ metarSegments: {
+ wind: metarWindGroup,
+ visibility,
+ temp: tempGroup,
+ qnh: `Q${qnh.toString().padStart(4, '0')}`
+ },
+ readability: readability.level,
+ readabilityWord: readability.word,
+ readabilityPhrase: `Readability ${readability.word}`,
+ frequencies,
+ frequencyWords,
+ atisFreq: airport.freqs.atis,
+ deliveryFreq: airport.freqs.delivery,
+ groundFreq: airport.freqs.ground,
+ towerFreq: airport.freqs.tower,
+ departureFreq: airport.freqs.departure,
+ approachFreq: airport.freqs.approach,
+ centerFreq: airport.freqs.center,
+ transLevel: airport.transLevel,
+ remarks
+ }
+}
diff --git a/shared/learn/types.ts b/shared/learn/types.ts
new file mode 100644
index 0000000..20cdd8d
--- /dev/null
+++ b/shared/learn/types.ts
@@ -0,0 +1,141 @@
+export type BlankWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
+
+export type FrequencyType = 'ATIS' | 'DEL' | 'GND' | 'TWR' | 'DEP' | 'APP' | 'CTR'
+
+export type Frequency = {
+ type: FrequencyType
+ label: string
+ value: string
+}
+
+export type AirportData = {
+ icao: string
+ name: string
+ city: string
+ runways: string[]
+ stands: string[]
+ sids: string[]
+ transitions: string[]
+ approaches: string[]
+ taxi: string[]
+ freqs: {
+ atis: string
+ delivery: string
+ ground: string
+ tower: string
+ departure: string
+ approach: string
+ center: string
+ }
+ transLevel: string
+}
+
+export type AirlineData = {
+ code: string
+ call: string
+}
+
+export type Scenario = {
+ callsign: string
+ airlineCode: string
+ airlineCall: string
+ radioCall: string
+ callsignNato: string
+ flightNumber: string
+ flightNumberWords: string
+ airport: AirportData
+ destination: AirportData
+ runway: string
+ runwayWords: string
+ stand: string
+ taxiRoute: string
+ sid: string
+ transition: string
+ approach: string
+ altitudes: {
+ initial: number
+ climb: number
+ initialWords: string
+ climbWords: string
+ }
+ squawk: string
+ squawkWords: string
+ qnh: number
+ qnhWords: string
+ atisCode: string
+ atisText: string
+ atisSummary: {
+ runway: string
+ wind: string
+ visibility: string
+ temperature: string
+ dewpoint: string
+ qnh: string
+ }
+ wind: string
+ windWords: string
+ visibility: string
+ visibilityWords: string
+ temperature: number
+ temperatureWords: string
+ dewpoint: number
+ dewpointWords: string
+ metar: string
+ metarSegments: {
+ wind: string
+ visibility: string
+ temp: string
+ qnh: string
+ }
+ readability: number
+ readabilityWord: string
+ readabilityPhrase: string
+ frequencies: Frequency[]
+ frequencyWords: Record
+ atisFreq: string
+ deliveryFreq: string
+ groundFreq: string
+ towerFreq: string
+ departureFreq: string
+ approachFreq: string
+ centerFreq: string
+ transLevel: string
+ remarks: string
+}
+
+export type LessonField = {
+ key: string
+ label: string
+ expected: (scenario: Scenario) => string
+ alternatives?: (scenario: Scenario) => string[]
+ threshold?: number
+ placeholder?: string
+ width?: BlankWidth
+ inputmode?: 'text' | 'numeric'
+}
+
+export type ReadbackSegment =
+ | { type: 'text'; text: string | ((scenario: Scenario) => string) }
+ | { type: 'field'; key: string; width?: BlankWidth }
+
+export type Lesson = {
+ id: string
+ title: string
+ desc: string
+ keywords: string[]
+ hints: string[]
+ fields: LessonField[]
+ readback: ReadbackSegment[]
+ defaultFrequency?: string
+ phrase: (scenario: Scenario) => string
+ info: (scenario: Scenario) => string[]
+ generate: () => Scenario
+}
+
+export type ModuleDef = {
+ id: string
+ title: string
+ subtitle: string
+ art: string
+ lessons: Lesson[]
+}