diff --git a/app/pages/learn.vue b/app/pages/learn.vue
index 026e6b7..6ce7fc0 100644
--- a/app/pages/learn.vue
+++ b/app/pages/learn.vue
@@ -10,12 +10,15 @@
OpenSquawk
|
- Training Hub
+ Pilot Voice Prep
- Missions, XP bursts and animated badges that actually react to your flying. Stored locally.
+ Build confident pilot readbacks with scenario-driven missions, instant scoring, and badges that react to your flying.
@@ -698,6 +714,18 @@ type ModuleDef = {
lessons: Lesson[]
}
+type Objective = {
+ id: string
+ title: string
+ description: string
+ progress: number
+ goal: number
+ status: string
+ icon: string
+ complete: boolean
+ moduleId?: string
+}
+
type FieldState = {
key: string
label: string
@@ -1932,7 +1960,7 @@ const modules = ref([
]
}
])
-const panel = ref<'hub' | 'module' | 'progress'>('hub')
+const panel = ref<'hub' | 'module'>('hub')
const current = ref(null)
const activeLesson = ref(null)
const scenario = ref(null)
@@ -1966,8 +1994,6 @@ const level = computed(() => 1 + Math.floor(xp.value / XP_PER_LEVEL))
const nextLevel = computed(() => level.value + 1)
const levelProgress = computed(() => Math.min(100, Math.round(((xp.value % XP_PER_LEVEL) / XP_PER_LEVEL) * 100)))
const xpToNextLevel = computed(() => Math.max(0, level.value * XP_PER_LEVEL - xp.value))
-const seasonPct = computed(() => Math.min(100, Math.round((xp.value % 1000) / 10)))
-
type BadgeTier = { id: string; name: string; xp: number; description: string }
const badgeTrack: BadgeTier[] = [
@@ -2433,6 +2459,15 @@ function quickContinue(id: string) {
activeLesson.value = next
}
+function resumeMissionObjective() {
+ const objective = missionObjective.value
+ if (objective?.moduleId) {
+ quickContinue(objective.moduleId)
+ return
+ }
+ panel.value = 'hub'
+}
+
function selectLesson(lesson: Lesson) {
activeLesson.value = lesson
}
@@ -2541,24 +2576,133 @@ function lessonScoreClass(modId: string, lesId: string) {
return 'is-progress'
}
-const dailies = ref([
- { id: 'd1', title: '3 readbacks ≥80%', sub: 'Reward: +50 XP', reward: 50 },
- { id: 'd2', title: 'Start 1 module', sub: 'Reward: +20 XP', reward: 20 },
- { id: 'd3', title: 'Play the target phrase', sub: 'Reward: +10 XP', reward: 10 }
-])
-
-const activeDailies = computed(() => dailies.value.length)
-
-const dailyHint = computed(() => {
- const count = activeDailies.value
- if (!count) return 'fresh missions'
- return `${count} daily${count === 1 ? '' : 's'}`
+const lessonsWithGreatScore = computed(() => {
+ let count = 0
+ Object.values(progress.value || {}).forEach(module => {
+ Object.values(module || {}).forEach(entry => {
+ if (entry && typeof entry.best === 'number' && entry.best >= 80) {
+ count += 1
+ }
+ })
+ })
+ return count
})
-function startDaily(daily: { id: string; reward: number; title: string }) {
- xp.value += daily.reward
- toastNow(`Daily: ${daily.title} · +${daily.reward} XP`)
- dailies.value = dailies.value.filter(item => item.id !== daily.id)
+const modulesStartedCount = computed(() => {
+ let count = 0
+ modules.value.forEach(module => {
+ const moduleProgress = progress.value[module.id]
+ if (!moduleProgress) return
+ const active = Object.values(moduleProgress).some(entry => {
+ if (!entry) return false
+ return Boolean(entry.done) || (typeof entry.best === 'number' && entry.best > 0)
+ })
+ if (active) count += 1
+ })
+ return count
+})
+
+const dailyObjectives = computed(() => {
+ const readbacks = lessonsWithGreatScore.value
+ const started = modulesStartedCount.value > 0
+ const audioPlayed = hasSpokenTarget.value
+
+ return [
+ {
+ id: 'daily-readbacks',
+ title: '3 readbacks ≥80%',
+ description: 'Score at least 80% in three different lessons.',
+ progress: readbacks,
+ goal: 3,
+ status: readbacks >= 3 ? 'Goal reached' : `${Math.max(0, 3 - Math.min(readbacks, 3))} more ≥80%`,
+ icon: 'mdi-target-account',
+ complete: readbacks >= 3
+ },
+ {
+ id: 'daily-module',
+ title: 'Start 1 module',
+ description: 'Open a mission set and clear the first lesson.',
+ progress: started ? 1 : 0,
+ goal: 1,
+ status: started ? 'Module started' : 'Pick a module to open',
+ icon: 'mdi-flag-variant',
+ complete: started
+ },
+ {
+ id: 'daily-audio',
+ title: 'Play the target phrase',
+ description: 'Listen to the controller audio before responding.',
+ progress: audioPlayed ? 1 : 0,
+ goal: 1,
+ status: audioPlayed ? 'Played once today' : 'Play the controller audio',
+ icon: 'mdi-volume-high',
+ complete: audioPlayed
+ }
+ ]
+})
+
+const missionObjective = computed(() => {
+ const nextModule = modules.value.find(module => !moduleCompleted(module.id))
+ if (!nextModule) return null
+ const total = nextModule.lessons.length || 1
+ const completed = Math.min(total, Math.max(0, doneCount(nextModule.id)))
+ const status = completed >= total ? 'Module complete' : `Lesson ${Math.min(completed + 1, total)} of ${total}`
+ return {
+ id: `module-${nextModule.id}`,
+ title: `Continue ${nextModule.title}`,
+ description: nextModule.subtitle,
+ progress: completed,
+ goal: total,
+ status,
+ icon: 'mdi-flag-checkered',
+ complete: completed >= total,
+ moduleId: nextModule.id
+ }
+})
+
+const badgeObjective = computed(() => {
+ const upcoming = nextBadge.value
+ if (!upcoming) return null
+ const previous = badgeTrack.slice().reverse().find(badge => badge.xp <= xp.value) || badgeTrack[0]
+ const span = Math.max(1, upcoming.xp - previous.xp)
+ const gained = Math.max(0, xp.value - previous.xp)
+ return {
+ id: `badge-${upcoming.id}`,
+ title: `${upcoming.name} badge`,
+ description: upcoming.description,
+ progress: gained,
+ goal: span,
+ status: `${nextBadgeXpRemaining.value} XP to unlock`,
+ icon: 'mdi-shield-star',
+ complete: false
+ }
+})
+
+const objectiveList = computed(() => {
+ const list: Objective[] = []
+ if (missionObjective.value) list.push(missionObjective.value)
+ list.push(...dailyObjectives.value)
+ if (badgeObjective.value) list.push(badgeObjective.value)
+ return list
+})
+
+const primaryObjective = computed(() => objectiveList.value.find(objective => !objective.complete) || objectiveList.value[0] || null)
+
+const primaryObjectiveProgress = computed(() => (primaryObjective.value ? objectiveProgressPct(primaryObjective.value) : 0))
+
+function objectiveProgressPct(objective: Objective): number {
+ if (!objective.goal || objective.goal <= 0) {
+ return objective.complete ? 100 : 0
+ }
+ const clamped = Math.min(objective.goal, Math.max(0, objective.progress))
+ return Math.min(100, Math.round((clamped / objective.goal) * 100))
+}
+
+function objectiveBadgeLabel(objective: Objective): string {
+ if (objective.complete) return 'Completed'
+ if (!objective.goal || objective.goal <= 0) return 'In progress'
+ const clamped = Math.min(objective.goal, Math.max(0, Math.round(objective.progress)))
+ return `${clamped}/${objective.goal}`
}
function toastNow(text: string) {
@@ -2804,10 +2948,6 @@ function stopAudio() {
}
}
-function testBeep() {
- void say('Frankfurt Ground, Lufthansa one two three, request taxi.')
-}
-
onMounted(() => {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.getVoices()
@@ -2912,6 +3052,38 @@ onMounted(() => {
align-items: center
}
+.next-objective {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ padding: 8px 12px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: color-mix(in srgb, var(--text) 6%, transparent);
+ min-width: 180px;
+ max-width: 240px;
+}
+
+.next-label {
+ font-size: 10px;
+ letter-spacing: .18em;
+ text-transform: uppercase;
+ color: var(--t3);
+}
+
+.next-value {
+ font-weight: 600;
+ color: var(--t2);
+ text-align: right;
+}
+
+.next-status {
+ font-size: 12px;
+ color: var(--t3);
+ text-align: right;
+}
+
.icon-btn {
border: 1px solid var(--border);
background: transparent;
@@ -3066,6 +3238,13 @@ onMounted(() => {
color: var(--t3)
}
+.hero-orb-caption {
+ font-size: 11px;
+ letter-spacing: .08em;
+ color: var(--t3);
+ text-transform: uppercase;
+}
+
.hero-highlight-meta {
display: flex;
flex-direction: column;
@@ -3100,6 +3279,63 @@ onMounted(() => {
transition: width .4s ease
}
+.objective-callout {
+ margin-top: 16px;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ padding: 16px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ background: color-mix(in srgb, var(--text) 6%, transparent);
+}
+
+.objective-body {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ flex: 1;
+}
+
+.objective-title-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ color: var(--t2);
+}
+
+.objective-title {
+ font-size: 16px;
+}
+
+.objective-progress {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.objective-progress-bar {
+ flex: 1;
+ height: 6px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--text) 8%, transparent);
+ border: 1px solid var(--border);
+ overflow: hidden;
+}
+
+.objective-progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent), var(--accent2));
+ transition: width .3s ease;
+}
+
+.objective-progress-meta {
+ font-size: 12px;
+ color: var(--t3);
+ white-space: nowrap;
+}
+
.hero-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
@@ -3315,6 +3551,20 @@ onMounted(() => {
opacity: 1
}
+.challenge-card.done {
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ box-shadow: 0 10px 20px rgba(0, 0, 0, .18);
+}
+
+.challenge-card.done::after {
+ opacity: .4;
+}
+
+.challenge-card.done:hover {
+ transform: none;
+}
+
.card-head {
display: flex;
justify-content: space-between;
@@ -3331,6 +3581,11 @@ onMounted(() => {
text-transform: uppercase
}
+.badge.complete {
+ border-color: color-mix(in srgb, var(--accent) 45%, transparent);
+ color: var(--accent);
+}
+
.card-title {
font-weight: 600;
margin: 6px 0 2px
@@ -3491,8 +3746,25 @@ onMounted(() => {
.tile-actions {
display: flex;
- flex-wrap: wrap;
- gap: 10px
+ flex-direction: column;
+ gap: 10px;
+ align-items: stretch
+}
+
+.tile-actions .btn {
+ width: 100%;
+ justify-content: center;
+}
+
+@media (min-width: 720px) {
+ .tile-actions {
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+
+ .tile-actions .btn {
+ flex: 1 1 50%;
+ }
}
/* Play */
@@ -3688,29 +3960,6 @@ onMounted(() => {
font-weight: 700
}
-/* Progress */
-.progress-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: 12px;
- margin-top: 10px
-}
-
-.list {
- margin-top: 8px
-}
-
-.list-row {
- display: flex;
- justify-content: space-between;
- border-bottom: 1px dashed color-mix(in srgb, var(--text) 12%, transparent);
- padding: 8px 0
-}
-
-.list-row:last-child {
- border-bottom: 0
-}
-
/* Responsive */
@media (max-width: 980px) {
.hero-panel {
@@ -3724,6 +3973,37 @@ onMounted(() => {
.stats {
display: none
}
+
+ .objective-callout {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .objective-callout .btn {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+@media (max-width: 640px) {
+ .objective-progress {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+ }
+
+ .objective-progress-meta {
+ white-space: normal;
+ }
+
+ .next-objective {
+ align-items: flex-start;
+ }
+
+ .next-value,
+ .next-status {
+ text-align: left;
+ }
}
@@ -3855,6 +4135,8 @@ onMounted(() => {
flex-wrap: wrap;
gap: 6px;
line-height: 1.6;
+ text-transform: uppercase;
+ letter-spacing: .06em;
}
.blank {
@@ -3877,6 +4159,11 @@ onMounted(() => {
min-width: 60px;
font-size: 14px;
outline: none;
+ text-transform: uppercase;
+}
+
+.blank input::placeholder {
+ text-transform: uppercase;
}
.blank.size-xs { min-width: 70px; }
@@ -3912,6 +4199,8 @@ onMounted(() => {
margin-top: 4px;
font-size: 11px;
color: var(--t3);
+ text-transform: none;
+ letter-spacing: normal;
}
.field-checks {