mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 01:06:28 +08:00
feat: update classroom feedback round 2 content
This commit is contained in:
@@ -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;
|
||||
|
||||
43
docs/plans/2026-02-27-classroom-feedback-round2-design.md
Normal file
43
docs/plans/2026-02-27-classroom-feedback-round2-design.md
Normal 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`
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user