feat: update classroom feedback round 2 content

This commit is contained in:
itsrubberduck
2026-02-27 13:03:04 +01:00
parent 1e3cff1770
commit e792b09671
4 changed files with 249 additions and 10 deletions

View File

@@ -1035,6 +1035,23 @@
{{ info }}
</div>
</div>
<div v-if="lessonReference.length" class="reference-section">
<button
class="reference-toggle"
type="button"
@click="referenceOpen = !referenceOpen"
:aria-expanded="referenceOpen ? 'true' : 'false'"
>
<v-icon size="16">{{ referenceOpen ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
<span class="reference-toggle-label">Reference data</span>
</button>
<div v-if="referenceOpen" class="reference-table" :class="{ 'audio-blur': audioContentHidden }">
<div v-for="entry in lessonReference" :key="entry.label" class="reference-row">
<span class="reference-label">{{ entry.label }}</span>
<span class="reference-value">{{ entry.value }}</span>
</div>
</div>
</div>
</div>
<div class="col">
@@ -1182,6 +1199,9 @@
Next mission: {{ nextMissionMeta.module.title }} · Mission {{ nextMissionMeta.position }} of
{{ nextMissionMeta.total }}
</div>
<div v-else-if="isAtEndOfCurriculum" class="mission-footer-hint">
You've completed all available lessons! More content is coming soon.
</div>
<div v-else class="mission-footer-hint">
Last lesson in this mission.
</div>
@@ -2686,6 +2706,7 @@ type CachedAudio = { base64: string; mime?: string; model?: string | null; speed
const sayCache = new Map<string, CachedAudio>()
const pendingSayRequests = new Map<string, Promise<CachedAudio>>()
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;

View File

@@ -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`

View File

@@ -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' }

View File

@@ -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
}