From 837c1ef623575b4f9c45ac3a14c4092be0465b87 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Fri, 27 Feb 2026 13:03:04 +0100 Subject: [PATCH] feat: update classroom feedback round 2 content --- app/pages/classroom.vue | 136 ++++++++++++++++++++++++++++++++++++ shared/data/learnModules.ts | 74 +++++++++++++++++--- shared/learn/types.ts | 6 ++ 3 files changed, 206 insertions(+), 10 deletions(-) diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index d0ed58b..8598e7e 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1035,6 +1035,23 @@ {{ info }} +
+ +
+
+ {{ entry.label }} + {{ entry.value }} +
+
+
@@ -1182,6 +1199,9 @@ Next mission: {{ nextMissionMeta.module.title }} · Mission {{ nextMissionMeta.position }} of {{ nextMissionMeta.total }}
+ @@ -2686,6 +2706,7 @@ type CachedAudio = { base64: string; mime?: string; model?: string | null; speed const sayCache = new Map() const pendingSayRequests = new Map>() const audioReveal = ref(true) +const referenceOpen = ref(false) const toast = ref({show: false, text: ''}) const showSettings = ref(false) @@ -3241,6 +3262,45 @@ const targetPhrase = computed(() => { return displayCallsign(activeLesson.value.phrase(scenario.value), scenario.value) }) const lessonInfo = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.info(scenario.value) : [])) + +const lessonReference = computed(() => { + if (!activeLesson.value || !scenario.value) return [] + const lesson = activeLesson.value + const s = scenario.value + if (lesson.reference) return lesson.reference(s) + const entries: { label: string; value: string }[] = [] + const fieldKeys = new Set(lesson.fields.map(f => f.key)) + const phraseText = lesson.phrase(s) + const hasCallsign = fieldKeys.has('callsign') || fieldKeys.has('rc-callsign') + || fieldKeys.has('tko-callsign') || fieldKeys.has('landing-callsign') + || phraseText.includes(s.radioCall) + if (hasCallsign) { + entries.push({ label: 'Callsign', value: s.callsign }) + entries.push({ label: 'Radio call', value: s.radioCall }) + if (s.airlineCode && s.airlineCall) { + entries.push({ label: 'Airline', value: `${s.airlineCode} = ${s.airlineCall}` }) + } + } + if (phraseText.includes(s.airport.city) || phraseText.includes(s.airport.icao)) { + entries.push({ label: 'Airport', value: `${s.airport.icao} ${s.airport.name}` }) + } + if (phraseText.includes(s.destination.city) || phraseText.includes(s.destination.icao)) { + entries.push({ label: 'Destination', value: `${s.destination.icao} ${s.destination.name}` }) + } + if (phraseText.includes(s.sid)) { + entries.push({ label: 'SID', value: s.sid }) + } + if (phraseText.includes(s.transition)) { + entries.push({ label: 'Transition', value: s.transition }) + } + if (phraseText.includes(s.approach)) { + entries.push({ label: 'Approach', value: s.approach }) + } + if (s.arrivalStar && phraseText.includes(s.arrivalStar)) { + entries.push({ label: 'STAR', value: s.arrivalStar }) + } + return entries +}) const showScenarioPracticeHint = computed(() => { if (!current.value || !activeLesson.value) return false const index = modules.value.findIndex(module => module.id === current.value?.id) @@ -3324,6 +3384,15 @@ const lessonHasInput = computed(() => { }) }) +const isAtEndOfCurriculum = computed(() => { + if (!current.value || !activeLesson.value) return false + if (nextLessonMeta.value || nextMissionMeta.value) return false + const lastModule = modules.value[modules.value.length - 1] + if (!lastModule || current.value.id !== lastModule.id) return false + const lessons = lastModule.lessons + return activeLesson.value.id === lessons[lessons.length - 1]?.id +}) + const canAdvanceLesson = computed(() => Boolean(nextLessonMeta.value || nextMissionMeta.value)) const missionFooterNoop = () => { @@ -3341,6 +3410,15 @@ const missionFooterPrimary = computed(() => { } if (!lessonHasInput.value) { + if (isAtEndOfCurriculum.value) { + return { + label: 'Back to hub', + icon: 'mdi-view-dashboard', + disabled: false, + action: () => { panel.value = 'hub' }, + mode: 'is-skip' + } + } return { label: 'Skip lesson', icon: 'mdi-skip-next', @@ -3350,6 +3428,16 @@ const missionFooterPrimary = computed(() => { } } + if (isAtEndOfCurriculum.value) { + return { + label: 'Back to hub', + icon: 'mdi-view-dashboard', + disabled: false, + action: () => { panel.value = 'hub' }, + mode: 'is-next' + } + } + const icon = nextLessonMeta.value ? 'mdi-arrow-right' : nextMissionMeta.value ? 'mdi-flag-checkered' : 'mdi-arrow-right' return { label: nextActionLabel.value, @@ -6504,6 +6592,54 @@ onMounted(() => { opacity: 0.8; } +.reference-section { + margin-top: 8px; + border-top: 1px solid var(--b2, rgba(255, 255, 255, 0.08)); + padding-top: 6px; +} + +.reference-toggle { + display: flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: var(--t2, rgba(255, 255, 255, 0.6)); + font-size: 12px; + cursor: pointer; + padding: 2px 0; + transition: color 0.15s; +} + +.reference-toggle:hover { + color: var(--t1, rgba(255, 255, 255, 0.9)); +} + +.reference-toggle-label { + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.reference-table { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 12px; + padding: 6px 0 2px 20px; + font-size: 12px; + line-height: 1.6; +} + +.reference-label { + color: var(--t2, rgba(255, 255, 255, 0.6)); + white-space: nowrap; +} + +.reference-value { + color: var(--t1, rgba(255, 255, 255, 0.9)); + font-family: var(--font-mono, monospace); +} + .audio-blur { filter: blur(8px); pointer-events: none; diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts index 2f34ca8..33dc12d 100644 --- a/shared/data/learnModules.ts +++ b/shared/data/learnModules.ts @@ -970,10 +970,21 @@ const readbackLessons: Lesson[] = [ 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.' + 'Order: wind – runway – cleared for takeoff – call sign.', + 'Write the wind as direction/speed, e.g. 030/12.' ], fields: [ + { + key: 'tko-wind', + label: 'Wind', + expected: scenario => scenario.wind, + alternatives: scenario => [ + scenario.wind, + `${scenario.wind}KT`, + scenario.windWords + ], + width: 'md' + }, { key: 'tko-runway', label: 'Runway', @@ -994,7 +1005,9 @@ const readbackLessons: Lesson[] = [ } ], readback: [ - { type: 'text', text: 'Runway ' }, + { type: 'text', text: 'Wind ' }, + { type: 'field', key: 'tko-wind', width: 'md' }, + { type: 'text', text: ', runway ' }, { type: 'field', key: 'tko-runway', width: 'sm' }, { type: 'text', text: ', cleared for takeoff, ' }, { type: 'field', key: 'tko-callsign', width: 'lg' } @@ -1500,10 +1513,21 @@ const readbackLessons: Lesson[] = [ desc: 'Read back the landing clearance', keywords: ['Tower', 'Landing', 'Readback'], hints: [ - 'Lead with the runway, then "cleared to land".', - 'Keep the call sign at the end.' + 'Order: wind – runway – cleared to land – call sign.', + 'Write the wind as direction/speed, e.g. 260/08.' ], fields: [ + { + key: 'landing-wind', + label: 'Wind', + expected: scenario => scenario.arrivalWind, + alternatives: scenario => [ + scenario.arrivalWind, + `${scenario.arrivalWind}KT`, + scenario.arrivalWindWords + ], + width: 'md' + }, { key: 'landing-runway', label: 'Runway', @@ -1527,7 +1551,9 @@ const readbackLessons: Lesson[] = [ } ], readback: [ - { type: 'text', text: 'Runway ' }, + { type: 'text', text: 'Wind ' }, + { type: 'field', key: 'landing-wind', width: 'md' }, + { type: 'text', text: ', runway ' }, { type: 'field', key: 'landing-runway', width: 'sm' }, { type: 'text', text: ', cleared to land, ' }, { type: 'field', key: 'landing-callsign', width: 'lg' } @@ -2863,9 +2889,21 @@ const fullFlightLessons: Lesson[] = [ desc: 'Acknowledge the takeoff clearance', keywords: ['Tower', 'Departure', 'Flow'], hints: [ - 'Order: runway – cleared for takeoff – call sign.' + 'Order: wind – runway – cleared for takeoff – call sign.', + 'Write the wind as direction/speed, e.g. 030/12.' ], fields: [ + { + key: 'full-tko-wind', + label: 'Wind', + expected: scenario => scenario.wind, + alternatives: scenario => [ + scenario.wind, + `${scenario.wind}KT`, + scenario.windWords + ], + width: 'md' + }, { key: 'full-tko-runway', label: 'Runway', @@ -2886,7 +2924,9 @@ const fullFlightLessons: Lesson[] = [ } ], readback: [ - { type: 'text', text: 'Runway ' }, + { type: 'text', text: 'Wind ' }, + { type: 'field', key: 'full-tko-wind', width: 'md' }, + { type: 'text', text: ', runway ' }, { type: 'field', key: 'full-tko-runway', width: 'sm' }, { type: 'text', text: ', cleared for takeoff, ' }, { type: 'field', key: 'full-tko-callsign', width: 'lg' } @@ -3170,9 +3210,21 @@ const fullFlightLessons: Lesson[] = [ desc: 'Confirm the landing clearance', keywords: ['Tower', 'Landing', 'Flow'], hints: [ - 'Lead with the runway, then "cleared to land".' + 'Order: wind – runway – cleared to land – call sign.', + 'Write the wind as direction/speed, e.g. 260/08.' ], fields: [ + { + key: 'full-landing-wind', + label: 'Wind', + expected: scenario => scenario.arrivalWind, + alternatives: scenario => [ + scenario.arrivalWind, + `${scenario.arrivalWind}KT`, + scenario.arrivalWindWords + ], + width: 'md' + }, { key: 'full-landing-runway', label: 'Runway', @@ -3193,7 +3245,9 @@ const fullFlightLessons: Lesson[] = [ } ], readback: [ - { type: 'text', text: 'Runway ' }, + { type: 'text', text: 'Wind ' }, + { type: 'field', key: 'full-landing-wind', width: 'md' }, + { type: 'text', text: ', runway ' }, { type: 'field', key: 'full-landing-runway', width: 'sm' }, { type: 'text', text: ', cleared to land, ' }, { type: 'field', key: 'full-landing-callsign', width: 'lg' } diff --git a/shared/learn/types.ts b/shared/learn/types.ts index 4cac165..3748178 100644 --- a/shared/learn/types.ts +++ b/shared/learn/types.ts @@ -157,6 +157,11 @@ export type ReadbackSegment = | { type: 'text'; text: string | ((scenario: Scenario) => string) } | { type: 'field'; key: string; width?: BlankWidth } +export type ReferenceEntry = { + label: string + value: string +} + export type Lesson = { id: string title: string @@ -168,6 +173,7 @@ export type Lesson = { defaultFrequency?: string phrase: (scenario: Scenario) => string info: (scenario: Scenario) => string[] + reference?: (scenario: Scenario) => ReferenceEntry[] generate: () => Scenario }