From e792b096712981b27430782fad4a19aee24b3e04 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 ++++++++++++++++++ ...-02-27-classroom-feedback-round2-design.md | 43 ++++++ shared/data/learnModules.ts | 74 ++++++++-- shared/learn/types.ts | 6 + 4 files changed, 249 insertions(+), 10 deletions(-) create mode 100644 docs/plans/2026-02-27-classroom-feedback-round2-design.md 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/docs/plans/2026-02-27-classroom-feedback-round2-design.md b/docs/plans/2026-02-27-classroom-feedback-round2-design.md new file mode 100644 index 0000000..94945b5 --- /dev/null +++ b/docs/plans/2026-02-27-classroom-feedback-round2-design.md @@ -0,0 +1,43 @@ +# Classroom Feedback Round 2 — Design + +**Date**: 2026-02-27 +**Source**: User feedback from Russ and Samuel + +## Changes + +### 1. End-of-Content Messaging + +When the user reaches the last lesson of the last module, the footer currently shows "Last lesson in this mission." with no next action available. Users think the app is broken. + +**Fix**: New computed `isAtEndOfCurriculum` checks if user is on last lesson of last module. When true: +- Footer text: "You've completed all available lessons! More content is coming soon." +- Primary button becomes "Back to hub" (instead of disabled "Next lesson") +- No disabled/dead buttons + +**Files**: `app/pages/classroom.vue` (footer template ~line 1185, computeds ~line 3250-3330) + +### 2. Collapsible Reference Data Table + +Users don't know airline codes (AFR = Air France), nav aids (ANEKI), or other aviation-specific data. Need a reference table per lesson. + +**Fix**: +- New optional field on `Lesson` type: `reference?: (scenario: Scenario) => { label: string; value: string }[]` +- Collapsible "Reference" section in the briefing panel, below hints +- Auto-generate fallback from scenario data (callsign, airline, airport, runway, frequencies) when lesson doesn't define custom reference +- State (open/closed) persisted in learn config + +**Files**: `shared/learn/types.ts`, `shared/data/learnModules.ts` (optional per-lesson), `app/pages/classroom.vue` (briefing panel UI) + +### 3. Add Wind Field to Takeoff/Landing Lessons + +ATC says "wind 030/12, runway 26R, cleared for takeoff" but the readback template has no wind field. Users hear "wind" first and enter it in the runway field. + +**Fix**: Add wind field to 4 lessons, placed before the runway field to match audio order: +- `takeoff` (line ~968) +- `landing-clearance` (line ~1498) +- `full-takeoff` (line ~2861) +- `full-landing` (line ~3168) + +Wind field accepts formats: "030/12", "030/12KT", spoken words. + +**Files**: `shared/data/learnModules.ts` 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 }