feat: update classroom feedback round 2 content

This commit is contained in:
itsrubberduck
2026-02-27 13:03:04 +01:00
parent 46c39fe40b
commit 837c1ef623
3 changed files with 206 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

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