mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 01:06:28 +08:00
feat(classroom): show the full curriculum and raise the mastery bar
The overview rendered four anonymous module tiles and half a page of empty space, which undersold 63 existing lessons. It now stacks four module sections that list every lesson as a chip with status and variant dots, and the header states the real scope: 4 modules, 63 lessons, 262 variations. Mastery moves from a flat two-variation counter to per-module thresholds (3/4/4/5) backed by persisted variant signatures — a hash over each roll's expected answers. That triples the practice runs to full mastery without authoring a single new lesson, and fixes a dedup hole where the same variation counted twice after a page reload. Existing progress is preserved: `done` is sticky, so raising the bar never revokes a check mark someone already earned. Also fixes `yarn test`, which passed quoted globs Node 20 does not expand — it exited 0 without running a single test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -159,55 +159,80 @@
|
||||
<div class="hub-head">
|
||||
<h2 class="h2">Build the radio patterns before Live ATC</h2>
|
||||
<div class="muted">Start with Foundations, master the mandatory elements, then practise a complete guided flight.</div>
|
||||
<div class="training-data-notice" role="note">
|
||||
<v-icon size="18">mdi-flask-outline</v-icon>
|
||||
Synthetic training data — not for operational use
|
||||
<div class="curriculum-stats">
|
||||
<span class="curriculum-stat"><strong>{{ curriculumStats.modules }}</strong> modules</span>
|
||||
<span class="curriculum-stat"><strong>{{ curriculumStats.lessons }}</strong> lessons</span>
|
||||
<span class="curriculum-stat"><strong>{{ curriculumStats.variations }}</strong> practice variations</span>
|
||||
<span class="curriculum-stat is-accent"><strong>{{ curriculumStats.masteredPct }}%</strong> mastered</span>
|
||||
<span class="curriculum-stat is-warn" role="note">
|
||||
<v-icon size="14">mdi-flask-outline</v-icon>
|
||||
Synthetic training data
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tiles">
|
||||
<div
|
||||
<div class="module-sections">
|
||||
<section
|
||||
v-for="m in modules"
|
||||
:key="m.id"
|
||||
class="tile"
|
||||
class="module-section"
|
||||
:class="tileClass(m.id)"
|
||||
@click="isModuleUnlocked(m.id) && handleModulePrimary(m.id)"
|
||||
>
|
||||
<div class="tile-media"
|
||||
:style="{ backgroundImage: `url(${m.art})` }">
|
||||
<span class="tile-badge">
|
||||
<v-icon size="16">mdi-format-list-numbered</v-icon>
|
||||
Step {{ moduleNumber(m.id) }} · Recommended order
|
||||
</span>
|
||||
</div>
|
||||
<div class="tile-body">
|
||||
<div class="tile-top">
|
||||
<div class="tile-title">
|
||||
<div class="module-section-head">
|
||||
<div class="module-section-art" :style="{ backgroundImage: `url(${m.art})` }" aria-hidden="true"></div>
|
||||
<div class="module-section-meta">
|
||||
<span class="module-section-step">
|
||||
<v-icon size="14">mdi-format-list-numbered</v-icon>
|
||||
Step {{ moduleNumber(m.id) }} · Recommended order
|
||||
</span>
|
||||
<h3 class="module-section-title">
|
||||
<v-icon size="18">mdi-flag-checkered</v-icon>
|
||||
{{ m.title }}
|
||||
</h3>
|
||||
<div class="muted small">{{ m.subtitle }}</div>
|
||||
<div v-if="pct(m.id) > 0" class="line">
|
||||
<div class="line-fill" :style="{ width: pct(m.id)+'%' }"></div>
|
||||
</div>
|
||||
<div class="module-section-counts">
|
||||
<span>{{ doneCount(m.id) }}/{{ m.lessons.length }} mastered</span>
|
||||
<span>{{ practisingCount(m.id) }} practising</span>
|
||||
<span>{{ variantsForModule(m.id) }} clean variations each</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="muted small">{{ m.subtitle }}</div>
|
||||
<div class="line">
|
||||
<div class="line-fill" :style="{ width: pct(m.id)+'%' }"></div>
|
||||
</div>
|
||||
<div class="tile-progress-meta">
|
||||
<span>{{ doneCount(m.id) }}/{{ m.lessons.length }} mastered</span>
|
||||
<span>{{ practisingCount(m.id) }} practising</span>
|
||||
</div>
|
||||
<div class="tile-actions">
|
||||
<button
|
||||
class="btn primary"
|
||||
@click="handleModulePrimary(m.id)"
|
||||
>
|
||||
<v-icon size="18">{{ modulePrimaryIcon(m.id) }}</v-icon>
|
||||
{{ modulePrimaryLabel(m.id) }}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<button class="btn primary module-section-cta" @click="handleModulePrimary(m.id)">
|
||||
<v-icon size="18">{{ modulePrimaryIcon(m.id) }}</v-icon>
|
||||
{{ modulePrimaryLabel(m.id) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="lesson-chips">
|
||||
<li v-for="(les, index) in m.lessons" :key="les.id">
|
||||
<button
|
||||
class="lesson-chip"
|
||||
:class="lessonScoreClass(m.id, les.id)"
|
||||
:title="`${les.title} — ${lessonScoreLabel(m.id, les.id)}`"
|
||||
@click="openLessonFromSearch(m.id, les.id)"
|
||||
>
|
||||
<v-icon class="lesson-chip-icon" size="16">{{ lessonScoreIcon(m.id, les.id) }}</v-icon>
|
||||
<span class="lesson-chip-body">
|
||||
<span class="lesson-chip-title">{{ les.title }}</span>
|
||||
<span class="lesson-chip-sub">{{ moduleNumber(m.id) }}.{{ index + 1 }} · {{ lessonScoreLabel(m.id, les.id) }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="lesson-chip-dots"
|
||||
:aria-label="`${lessonVariantCount(m.id, les.id)} of ${variantsForModule(m.id)} clean variations`"
|
||||
>
|
||||
<i
|
||||
v-for="n in variantsForModule(m.id)"
|
||||
:key="n"
|
||||
class="variant-dot"
|
||||
:class="{ 'is-on': n <= lessonVariantCount(m.id, les.id) }"
|
||||
></i>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1467,8 +1492,8 @@ import {useApi} from '~/composables/useApi'
|
||||
import {useAuthStore} from '~/stores/auth'
|
||||
import {
|
||||
CLASSROOM_ASSESSMENT_VERSION,
|
||||
CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
createDefaultLearnConfig,
|
||||
variantsForModule,
|
||||
} from '~~/shared/learn/config'
|
||||
import type {LearnConfig, LearnProgress, LearnState} from '~~/shared/learn/config'
|
||||
import {
|
||||
@@ -1476,6 +1501,7 @@ import {
|
||||
isCurrentMastery,
|
||||
updateMasteryProgress,
|
||||
} from '~~/shared/learn/assessment'
|
||||
import {lessonVariantSignature} from '~~/shared/learn/variantSignature'
|
||||
import {learnModules, seedFullFlightScenario} from '~~/shared/data/learnModules'
|
||||
import {
|
||||
createBaseScenario,
|
||||
@@ -2818,7 +2844,6 @@ const readbackFieldRefs = new Map<string, HTMLInputElement>()
|
||||
const result = ref<ScoreResult | null>(null)
|
||||
const evaluating = ref(false)
|
||||
const modelAnswerRevealed = ref(false)
|
||||
const variantCounted = ref(false)
|
||||
|
||||
// First-run nudge: after the very first readback is checked, highlight the
|
||||
// footer's primary button so learners realise they continue from there. Shown
|
||||
@@ -3071,13 +3096,14 @@ const audioContentHidden = computed(() => canPlayPrompt.value && cfg.value.audio
|
||||
const audioSpeedDisplay = computed(() => (cfg.value.audioSpeed ?? 1).toFixed(2))
|
||||
const currentVariantProgressLabel = computed(() => {
|
||||
if (!current.value || !activeLesson.value) return 'Practising'
|
||||
const threshold = variantsForModule(current.value.id)
|
||||
const entry = progress.value[current.value.id]?.[activeLesson.value.id]
|
||||
const variants = entry?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION
|
||||
? Math.min(CLASSROOM_VARIANTS_FOR_MASTERY, entry.successfulVariants || 0)
|
||||
? Math.min(threshold, entry.successfulVariants || 0)
|
||||
: 0
|
||||
return variants >= CLASSROOM_VARIANTS_FOR_MASTERY
|
||||
return isCurrentMastery(entry)
|
||||
? 'Mastered'
|
||||
: `${variants} of ${CLASSROOM_VARIANTS_FOR_MASTERY} clean variations`
|
||||
: `${variants} of ${threshold} clean variations`
|
||||
})
|
||||
|
||||
const instructorVoiceOptions = [
|
||||
@@ -3870,7 +3896,6 @@ function rollScenario(clear = false) {
|
||||
resetAnswers(true)
|
||||
resetAudioReveal()
|
||||
modelAnswerRevealed.value = false
|
||||
variantCounted.value = false
|
||||
if (clear) {
|
||||
result.value = null
|
||||
}
|
||||
@@ -4026,10 +4051,10 @@ function evaluate() {
|
||||
passed: summary.passed,
|
||||
}, {
|
||||
modelAnswerRevealed: modelAnswerRevealed.value,
|
||||
variantAlreadyCounted: variantCounted.value,
|
||||
signature: lessonVariantSignature(activeLesson.value, scenario.value),
|
||||
threshold: variantsForModule(modId),
|
||||
})
|
||||
|
||||
if (update.counted) variantCounted.value = true
|
||||
progress.value[modId][lesId] = update.progress
|
||||
} finally {
|
||||
evaluating.value = false
|
||||
@@ -4133,6 +4158,12 @@ function isLessonMastered(modId: string, lesId: string): boolean {
|
||||
return isCurrentMastery(progress.value[modId]?.[lesId])
|
||||
}
|
||||
|
||||
function lessonVariantCount(modId: string, lesId: string): number {
|
||||
const entry = progress.value[modId]?.[lesId]
|
||||
if (entry?.assessmentVersion !== CLASSROOM_ASSESSMENT_VERSION) return 0
|
||||
return Math.min(variantsForModule(modId), entry.successfulVariants || 0)
|
||||
}
|
||||
|
||||
function isLessonPractising(modId: string, lesId: string): boolean {
|
||||
const entry = progress.value[modId]?.[lesId]
|
||||
if (!entry) return false
|
||||
@@ -4152,6 +4183,24 @@ function pct(modId: string) {
|
||||
return Math.round((doneCount(modId) / module.lessons.length) * 100)
|
||||
}
|
||||
|
||||
/** Scope figures for the hub header, derived from the curriculum itself. */
|
||||
const curriculumStats = computed(() => {
|
||||
const list = modules.value
|
||||
const lessons = list.reduce((sum, module) => sum + module.lessons.length, 0)
|
||||
const variations = list.reduce(
|
||||
(sum, module) => sum + module.lessons.length * variantsForModule(module.id),
|
||||
0,
|
||||
)
|
||||
const mastered = list.reduce((sum, module) => sum + doneCount(module.id), 0)
|
||||
|
||||
return {
|
||||
modules: list.length,
|
||||
lessons,
|
||||
variations,
|
||||
masteredPct: lessons ? Math.round((mastered / lessons) * 100) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
function moduleNumber(modId: string): number {
|
||||
const index = modules.value.findIndex(module => module.id === modId)
|
||||
return index >= 0 ? index + 1 : 0
|
||||
@@ -5703,224 +5752,261 @@ onMounted(() => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: stretch
|
||||
.curriculum-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
.curriculum-stat {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||
color: var(--t3);
|
||||
font-size: 12px;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.curriculum-stat strong {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.curriculum-stat.is-accent {
|
||||
border-color: color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.curriculum-stat.is-accent strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.curriculum-stat.is-warn {
|
||||
gap: 5px;
|
||||
border-color: color-mix(in srgb, #fbbf24 26%, transparent);
|
||||
background: color-mix(in srgb, #fbbf24 8%, transparent);
|
||||
color: color-mix(in srgb, #fcd34d 82%, var(--text));
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.module-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
/* The footer is position: fixed — keep the last lesson chip clear of it. */
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.module-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||
box-shadow: 0 12px 26px rgba(0, 0, 0, .22);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 26px rgba(0, 0, 0, .25);
|
||||
transition: transform .25s ease, box-shadow .25s ease;
|
||||
position: relative;
|
||||
min-height: 360px
|
||||
}
|
||||
|
||||
.tile.is-fresh {
|
||||
border-color: color-mix(in srgb, var(--accent) 32%, transparent)
|
||||
.module-section.is-fresh {
|
||||
border-color: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
}
|
||||
|
||||
.tile.is-active {
|
||||
border-color: color-mix(in srgb, #fbbf24 28%, transparent)
|
||||
.module-section.is-active {
|
||||
border-color: color-mix(in srgb, #fbbf24 28%, transparent);
|
||||
}
|
||||
|
||||
.tile.is-complete {
|
||||
border-color: color-mix(in srgb, #22c55e 32%, transparent)
|
||||
.module-section.is-complete {
|
||||
border-color: color-mix(in srgb, #22c55e 32%, transparent);
|
||||
}
|
||||
|
||||
.tile.is-locked {
|
||||
filter: saturate(.7) brightness(.9)
|
||||
.module-section.is-locked {
|
||||
filter: saturate(.7) brightness(.9);
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, .3)
|
||||
.module-section-head {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--text) 3%, transparent);
|
||||
}
|
||||
|
||||
.tile-media {
|
||||
height: 140px;
|
||||
.module-section-art {
|
||||
height: 96px;
|
||||
border-radius: 14px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative
|
||||
}
|
||||
|
||||
.tile-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 65%, transparent), color-mix(in srgb, var(--accent2) 65%, transparent));
|
||||
color: #f8fafc;
|
||||
font-size: 12px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 6px 18px rgba(2, 6, 23, .4)
|
||||
}
|
||||
|
||||
.tile-body {
|
||||
padding: 14px;
|
||||
.module-section-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tile-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px
|
||||
}
|
||||
|
||||
.tile-title {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px
|
||||
}
|
||||
|
||||
.tile-status {
|
||||
.module-section-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
|
||||
color: var(--t3)
|
||||
}
|
||||
|
||||
.tile-status.is-fresh {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent)
|
||||
}
|
||||
|
||||
.tile-status.is-active {
|
||||
color: #fbbf24;
|
||||
border-color: color-mix(in srgb, #fbbf24 40%, transparent);
|
||||
background: color-mix(in srgb, #fbbf24 12%, transparent)
|
||||
}
|
||||
|
||||
.tile-status.is-complete {
|
||||
color: #22c55e;
|
||||
border-color: color-mix(in srgb, #22c55e 40%, transparent);
|
||||
background: color-mix(in srgb, #22c55e 14%, transparent)
|
||||
}
|
||||
|
||||
.tile-status.is-locked {
|
||||
gap: 5px;
|
||||
color: var(--t3);
|
||||
border-color: color-mix(in srgb, var(--text) 14%, transparent);
|
||||
background: color-mix(in srgb, var(--text) 4%, transparent)
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tile-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(6, 12, 34, .82);
|
||||
color: #f8fafc;
|
||||
.module-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: none
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tile-overlay-inner {
|
||||
/* A full-width bar at 0% just reads as a divider — keep it short and paired
|
||||
with the counts it describes. */
|
||||
.module-section-meta .line {
|
||||
max-width: 260px;
|
||||
height: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.module-section-counts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 240px
|
||||
}
|
||||
|
||||
.tile-overlay-title {
|
||||
font-weight: 600;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
color: var(--t3);
|
||||
font-size: 11px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tile-overlay-sub {
|
||||
font-size: 14px;
|
||||
color: rgba(248, 250, 252, .75)
|
||||
.module-section-cta {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tile-overlay-link {
|
||||
pointer-events: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 2px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
.lesson-chips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.lesson-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--text) 4%, transparent);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: border-color .18s ease, background .18s ease, transform .18s ease;
|
||||
}
|
||||
|
||||
.tile-overlay-link:hover,
|
||||
.tile-overlay-link:focus-visible {
|
||||
text-decoration: underline;
|
||||
color: #f8fafc;
|
||||
.lesson-chip:hover,
|
||||
.lesson-chip:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 44%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border-radius: 999px;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.line-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2))
|
||||
}
|
||||
|
||||
.tile-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.lesson-chip-icon {
|
||||
flex: none;
|
||||
color: var(--t3);
|
||||
font-size: 12px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase
|
||||
}
|
||||
|
||||
.tile-actions {
|
||||
.lesson-chip.is-great .lesson-chip-icon {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.lesson-chip.is-progress .lesson-chip-icon {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.lesson-chip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
margin-top: auto
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.tile-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
.lesson-chip-title {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lesson-chip-sub {
|
||||
color: var(--t3);
|
||||
font-size: 10px;
|
||||
letter-spacing: .07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lesson-chip-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.variant-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 24%, transparent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.variant-dot.is-on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.lesson-chip.is-great .variant-dot.is-on {
|
||||
border-color: #22c55e;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.tile-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
.module-section-head {
|
||||
grid-template-columns: 120px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.tile-actions .btn {
|
||||
flex: 1 1 50%;
|
||||
.module-section-cta {
|
||||
grid-column: auto;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.module-section-head {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.module-section-art {
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7408,7 +7494,7 @@ onMounted(() => {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.tile,
|
||||
.lesson-chip,
|
||||
.lesson,
|
||||
.challenge-card,
|
||||
.hero-orb,
|
||||
|
||||
94
docs/plans/2026-07-30-classroom-production-value.md
Normal file
94
docs/plans/2026-07-30-classroom-production-value.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Classroom Production Value Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Triple the perceived and actual scope of the Classroom without authoring new lessons, and fill the empty overview page with the 63 lessons that already exist.
|
||||
|
||||
**Architecture:** Mastery moves from a flat two-variant counter to per-module thresholds backed by persisted variant signatures (a hash over each roll's expected answers). The overview replaces four anonymous tiles with four stacked module sections that list every lesson.
|
||||
|
||||
**Tech Stack:** Nuxt 4, Vue 3, TypeScript, `node:test` via tsx.
|
||||
|
||||
Design: [2026-07-30-classroom-production-value-design.md](2026-07-30-classroom-production-value-design.md)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Variant signature utility
|
||||
|
||||
**Files:**
|
||||
- Create: `shared/learn/variantSignature.ts`
|
||||
- Test: `shared/learn/variantSignature.test.ts`
|
||||
|
||||
FNV-1a over `lesson.fields.map(f => f.expected(scenario)).join('')`, base36.
|
||||
Two rolls demanding the same answers collide by design; that is the point.
|
||||
|
||||
Tests: stable for the same scenario, differs for different expected answers,
|
||||
non-empty for a lesson with no fields.
|
||||
|
||||
### Task 2: Per-module thresholds and the new progress field
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/learn/config.ts`
|
||||
|
||||
Add `variantSignatures?: string[]` to `LessonProgress`. Replace
|
||||
`CLASSROOM_VARIANTS_FOR_MASTERY` with `CLASSROOM_VARIANTS_BY_MODULE`
|
||||
(`normalize` 3, `arc` 4, `decision-tree` 4, `full-flight` 5),
|
||||
`CLASSROOM_VARIANTS_DEFAULT` 3, and `variantsForModule(modId)`.
|
||||
|
||||
Keep the old constant exported as a deprecated alias equal to
|
||||
`CLASSROOM_VARIANTS_DEFAULT` only if a call site still needs it; otherwise
|
||||
remove it and fix the two call sites in `classroom.vue`.
|
||||
|
||||
### Task 3: Signature-aware mastery
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/learn/assessment.ts`
|
||||
- Test: `tests/shared/classroomCurriculum.test.ts`
|
||||
|
||||
`updateMasteryProgress(previous, assessment, { modelAnswerRevealed, signature, threshold })`.
|
||||
|
||||
- Counts when passed, not revealed, and `signature` is not already stored.
|
||||
- Appends the signature, sets `successfulVariants = max(previous, signatures.length)`.
|
||||
- **`done` is sticky:** `done: previous?.done || successfulVariants >= threshold`.
|
||||
This is what preserves existing customers' check marks when the threshold rises.
|
||||
|
||||
`isCurrentMastery(progress)` keeps its current shape — it reads `done`, so
|
||||
grandfathered entries stay mastered.
|
||||
|
||||
Tests: duplicate signature does not count; distinct signatures accumulate;
|
||||
revealed answer never counts; a legacy `{done:true, successfulVariants:2}` stays
|
||||
mastered under a threshold of 4; threshold lookup falls back to the default.
|
||||
|
||||
### Task 4: Wire the signature through the lesson runner
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/pages/classroom.vue` (evaluation at ~4021, `rollScenario` at ~3862,
|
||||
`currentVariantProgressLabel` at ~3072)
|
||||
|
||||
Compute the signature from `activeLesson.value` and `scenario.value` at
|
||||
evaluation time. Delete the `variantCounted` ref — persisted signatures replace
|
||||
it and fix the reload-dedup hole. Label reads `x of N clean variations` against
|
||||
the module threshold.
|
||||
|
||||
### Task 5: Lesson chips on the overview
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/pages/classroom.vue` (hub markup at 158-212, styles)
|
||||
|
||||
Four stacked module sections: header (art band, title, subtitle, progress bar,
|
||||
counts, Continue button) plus a grid of lesson chips. Each chip shows the lesson
|
||||
title, `lessonScoreIcon`, variant dots against the module threshold, and best
|
||||
score. Clicking a chip calls `openLessonFromSearch(modId, lesId)`, which already
|
||||
does exactly the right thing.
|
||||
|
||||
### Task 6: Scope figures in the hub header
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/pages/classroom.vue`
|
||||
|
||||
Computed `curriculumStats`: module count, lesson count, and total variations
|
||||
(sum of `lessons.length * variantsForModule(id)`). Rendered under the subtitle
|
||||
with overall mastery progress.
|
||||
|
||||
### Task 7: Verify and ship
|
||||
|
||||
`yarn test`, `yarn typecheck`, commit, merge into `release`, push.
|
||||
@@ -13,7 +13,7 @@
|
||||
"postinstall": "nuxt prepare && (git config core.hooksPath .githooks 2>/dev/null || true)",
|
||||
"typecheck": "vue-tsc --build",
|
||||
"sharp:rebuild": "SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn rebuild sharp",
|
||||
"test": "tsx --tsconfig tsconfig.tests.json --test \"tests/**/*.test.ts\" \"server/**/*.test.ts\" \"shared/**/*.test.ts\""
|
||||
"test": "tsx --tsconfig tsconfig.tests.json --test tests/*/*.test.ts server/*/*.test.ts shared/*/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/image": "^2.0.0",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
CLASSROOM_ASSESSMENT_VERSION,
|
||||
CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
type LessonProgress,
|
||||
} from './config'
|
||||
|
||||
@@ -43,13 +42,21 @@ export type ProgressUpdate = {
|
||||
export function updateMasteryProgress(
|
||||
previous: LessonProgress | undefined,
|
||||
assessment: AssessmentSummary,
|
||||
options: { modelAnswerRevealed: boolean; variantAlreadyCounted: boolean },
|
||||
options: { modelAnswerRevealed: boolean; signature: string; threshold: number },
|
||||
): ProgressUpdate {
|
||||
const currentVersion = previous?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION
|
||||
const previousVariants = currentVersion ? (previous?.successfulVariants || 0) : 0
|
||||
const counted = assessment.passed && !options.modelAnswerRevealed && !options.variantAlreadyCounted
|
||||
const previousSignatures = currentVersion ? (previous?.variantSignatures || []) : []
|
||||
|
||||
const counted = assessment.passed
|
||||
&& !options.modelAnswerRevealed
|
||||
&& !previousSignatures.includes(options.signature)
|
||||
|
||||
const variantSignatures = counted
|
||||
? [...previousSignatures, options.signature]
|
||||
: previousSignatures
|
||||
const successfulVariants = Math.min(
|
||||
CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
options.threshold,
|
||||
previousVariants + (counted ? 1 : 0),
|
||||
)
|
||||
|
||||
@@ -57,17 +64,24 @@ export function updateMasteryProgress(
|
||||
counted,
|
||||
progress: {
|
||||
best: Math.max(previous?.best || 0, assessment.score),
|
||||
done: successfulVariants >= CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
// Sticky: raising the threshold must never revoke a check mark someone
|
||||
// already earned under the old bar.
|
||||
done: (currentVersion && previous?.done === true) || successfulVariants >= options.threshold,
|
||||
assessmentVersion: CLASSROOM_ASSESSMENT_VERSION,
|
||||
successfulVariants,
|
||||
variantSignatures,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `done` already encodes the module threshold that applied when it was set, so
|
||||
* it is the single source of truth. Entries from before the versioned
|
||||
* assessment still need a review pass.
|
||||
*/
|
||||
export function isCurrentMastery(progress: LessonProgress | undefined): boolean {
|
||||
return Boolean(
|
||||
progress?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION
|
||||
&& progress.done
|
||||
&& (progress.successfulVariants || 0) >= CLASSROOM_VARIANTS_FOR_MASTERY,
|
||||
&& progress.done,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ export interface LessonProgress {
|
||||
done: boolean
|
||||
assessmentVersion?: number
|
||||
successfulVariants?: number
|
||||
/**
|
||||
* Signatures of the rolled scenarios already passed for this lesson, so the
|
||||
* same variation cannot be counted twice — not even across page reloads.
|
||||
*/
|
||||
variantSignatures?: string[]
|
||||
}
|
||||
|
||||
export type LearnProgress = Record<string, Record<string, LessonProgress>>
|
||||
@@ -25,7 +30,24 @@ export interface LearnState {
|
||||
}
|
||||
|
||||
export const CLASSROOM_ASSESSMENT_VERSION = 2
|
||||
export const CLASSROOM_VARIANTS_FOR_MASTERY = 2
|
||||
|
||||
/**
|
||||
* Clean variations required before a lesson counts as mastered. Short drills
|
||||
* need less repetition than a full scenario, so the bar rises with module
|
||||
* complexity. Every lesson rolls a fresh scenario, so the material is there.
|
||||
*/
|
||||
export const CLASSROOM_VARIANTS_BY_MODULE: Record<string, number> = {
|
||||
'normalize': 3,
|
||||
'arc': 4,
|
||||
'decision-tree': 4,
|
||||
'full-flight': 5,
|
||||
}
|
||||
|
||||
export const CLASSROOM_VARIANTS_DEFAULT = 3
|
||||
|
||||
export function variantsForModule(moduleId: string): number {
|
||||
return CLASSROOM_VARIANTS_BY_MODULE[moduleId] ?? CLASSROOM_VARIANTS_DEFAULT
|
||||
}
|
||||
|
||||
export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
|
||||
tts: false,
|
||||
|
||||
55
shared/learn/variantSignature.test.ts
Normal file
55
shared/learn/variantSignature.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { lessonVariantSignature } from './variantSignature'
|
||||
import type { Lesson, Scenario } from './types'
|
||||
|
||||
function lessonWithFields(keys: string[]): Pick<Lesson, 'fields'> {
|
||||
return {
|
||||
fields: keys.map(key => ({
|
||||
key,
|
||||
label: key,
|
||||
expected: (scenario: Scenario) => String((scenario as unknown as Record<string, unknown>)[key] ?? ''),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const scenarioA = { runway: '25L', squawk: '4711' } as unknown as Scenario
|
||||
const scenarioB = { runway: '07R', squawk: '4711' } as unknown as Scenario
|
||||
|
||||
describe('lessonVariantSignature', () => {
|
||||
it('is stable for the same scenario', () => {
|
||||
const lesson = lessonWithFields(['runway', 'squawk'])
|
||||
assert.equal(
|
||||
lessonVariantSignature(lesson, scenarioA),
|
||||
lessonVariantSignature(lesson, scenarioA),
|
||||
)
|
||||
})
|
||||
|
||||
it('differs when an expected answer differs', () => {
|
||||
const lesson = lessonWithFields(['runway', 'squawk'])
|
||||
assert.notEqual(
|
||||
lessonVariantSignature(lesson, scenarioA),
|
||||
lessonVariantSignature(lesson, scenarioB),
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores scenario values the lesson never asks for', () => {
|
||||
const lesson = lessonWithFields(['squawk'])
|
||||
assert.equal(
|
||||
lessonVariantSignature(lesson, scenarioA),
|
||||
lessonVariantSignature(lesson, scenarioB),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a non-empty token for a lesson without fields', () => {
|
||||
assert.ok(lessonVariantSignature(lessonWithFields([]), scenarioA).length > 0)
|
||||
})
|
||||
|
||||
it('survives a field whose expected() throws', () => {
|
||||
const lesson = {
|
||||
fields: [{ key: 'boom', label: 'boom', expected: () => { throw new Error('nope') } }],
|
||||
} as unknown as Pick<Lesson, 'fields'>
|
||||
assert.ok(lessonVariantSignature(lesson, scenarioA).length > 0)
|
||||
})
|
||||
})
|
||||
BIN
shared/learn/variantSignature.ts
Normal file
BIN
shared/learn/variantSignature.ts
Normal file
Binary file not shown.
@@ -7,7 +7,12 @@ import {
|
||||
isCurrentMastery,
|
||||
updateMasteryProgress,
|
||||
} from '~~/shared/learn/assessment'
|
||||
import { CLASSROOM_ASSESSMENT_VERSION } from '~~/shared/learn/config'
|
||||
import {
|
||||
CLASSROOM_ASSESSMENT_VERSION,
|
||||
CLASSROOM_VARIANTS_BY_MODULE,
|
||||
CLASSROOM_VARIANTS_DEFAULT,
|
||||
variantsForModule,
|
||||
} from '~~/shared/learn/config'
|
||||
|
||||
describe('Classroom curriculum contract', () => {
|
||||
it('exposes the four goal-led modules and a 17-step guided flight', () => {
|
||||
@@ -126,45 +131,96 @@ describe('Classroom assessment', () => {
|
||||
assert.equal(summary?.passed, false)
|
||||
})
|
||||
|
||||
it('requires two unrevealed variations for current mastery', () => {
|
||||
it('requires the module threshold in distinct unrevealed variations', () => {
|
||||
const passed = { score: 100, hits: 2, required: 2, similarity: 1, passed: true }
|
||||
const first = updateMasteryProgress(undefined, passed, {
|
||||
modelAnswerRevealed: false,
|
||||
variantAlreadyCounted: false,
|
||||
signature: 'aaa',
|
||||
threshold: 3,
|
||||
})
|
||||
assert.equal(first.counted, true)
|
||||
assert.equal(first.progress.done, false)
|
||||
assert.equal(first.progress.successfulVariants, 1)
|
||||
assert.deepEqual(first.progress.variantSignatures, ['aaa'])
|
||||
|
||||
const duplicate = updateMasteryProgress(first.progress, passed, {
|
||||
modelAnswerRevealed: false,
|
||||
variantAlreadyCounted: true,
|
||||
signature: 'aaa',
|
||||
threshold: 3,
|
||||
})
|
||||
assert.equal(duplicate.counted, false)
|
||||
assert.equal(duplicate.progress.successfulVariants, 1)
|
||||
|
||||
const revealed = updateMasteryProgress(first.progress, passed, {
|
||||
modelAnswerRevealed: true,
|
||||
variantAlreadyCounted: false,
|
||||
signature: 'bbb',
|
||||
threshold: 3,
|
||||
})
|
||||
assert.equal(revealed.progress.successfulVariants, 1)
|
||||
|
||||
const second = updateMasteryProgress(first.progress, passed, {
|
||||
modelAnswerRevealed: false,
|
||||
variantAlreadyCounted: false,
|
||||
signature: 'bbb',
|
||||
threshold: 3,
|
||||
})
|
||||
assert.equal(second.progress.done, true)
|
||||
assert.equal(isCurrentMastery(second.progress), true)
|
||||
assert.equal(second.progress.done, false)
|
||||
|
||||
const third = updateMasteryProgress(second.progress, passed, {
|
||||
modelAnswerRevealed: false,
|
||||
signature: 'ccc',
|
||||
threshold: 3,
|
||||
})
|
||||
assert.equal(third.progress.done, true)
|
||||
assert.equal(isCurrentMastery(third.progress), true)
|
||||
})
|
||||
|
||||
it('treats legacy completion as review needed rather than current mastery', () => {
|
||||
it('does not count a failed attempt', () => {
|
||||
const failed = { score: 40, hits: 1, required: 2, similarity: 0.4, passed: false }
|
||||
const update = updateMasteryProgress(undefined, failed, {
|
||||
modelAnswerRevealed: false,
|
||||
signature: 'aaa',
|
||||
threshold: 3,
|
||||
})
|
||||
assert.equal(update.counted, false)
|
||||
assert.equal(update.progress.successfulVariants, 0)
|
||||
assert.equal(update.progress.best, 40)
|
||||
})
|
||||
|
||||
it('keeps mastery earned under the old two-variation bar', () => {
|
||||
const legacy = {
|
||||
best: 100,
|
||||
done: true,
|
||||
assessmentVersion: CLASSROOM_ASSESSMENT_VERSION,
|
||||
successfulVariants: 2,
|
||||
}
|
||||
assert.equal(isCurrentMastery(legacy), true)
|
||||
|
||||
const passed = { score: 100, hits: 2, required: 2, similarity: 1, passed: true }
|
||||
const next = updateMasteryProgress(legacy, passed, {
|
||||
modelAnswerRevealed: false,
|
||||
signature: 'aaa',
|
||||
threshold: 4,
|
||||
})
|
||||
assert.equal(next.progress.done, true, 'raising the bar must not revoke a check mark')
|
||||
assert.equal(next.progress.successfulVariants, 3)
|
||||
})
|
||||
|
||||
it('treats pre-version-2 completion as review needed rather than current mastery', () => {
|
||||
assert.equal(isCurrentMastery({ best: 100, done: true }), false)
|
||||
assert.equal(
|
||||
isCurrentMastery({
|
||||
best: 100,
|
||||
done: true,
|
||||
assessmentVersion: CLASSROOM_ASSESSMENT_VERSION,
|
||||
successfulVariants: 2,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves per-module thresholds and falls back for unknown modules', () => {
|
||||
assert.equal(variantsForModule('normalize'), 3)
|
||||
assert.equal(variantsForModule('full-flight'), 5)
|
||||
assert.equal(variantsForModule('nope'), CLASSROOM_VARIANTS_DEFAULT)
|
||||
})
|
||||
|
||||
it('covers every shipped module with an explicit threshold', () => {
|
||||
for (const module of learnModules) {
|
||||
assert.ok(
|
||||
CLASSROOM_VARIANTS_BY_MODULE[module.id],
|
||||
`${module.id} has no explicit variant threshold`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user