Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 8x 8x 8x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 1x 4x 3x 4x 4x 1x 1x 5x 5x 5x 5x 5x 5x 5x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 1x 1x 1x 1x | 'use client'
import type { LinearEntryAssessment } from '@/lib/curriculum/linear-entry-policy'
import { css } from '../../../styled-system/css'
export interface LinearEntryDimension {
key: 'mastery' | 'volume' | 'accuracy' | 'speed'
label: string
met: boolean
/** Short human phrase, e.g. "73% over last 15, need 85%". */
detail: string
}
function pct(x: number): string {
return `${Math.round(x * 100)}%`
}
/**
* Turn the entry policy's verdict into parent-readable dimensions. Speed only
* appears when the policy actually checks it — a dimension that can't fail is
* noise.
*/
export function describeLinearEntry(entry: LinearEntryAssessment): LinearEntryDimension[] {
const dims: LinearEntryDimension[] = [
{
key: 'mastery',
label: 'Mastery',
met: entry.mastery.met,
detail: entry.mastery.met ? 'mastered' : `${pct(entry.mastery.pKnown)} mastered`,
},
{
key: 'volume',
label: 'Practice',
met: entry.volume.met,
detail: entry.volume.met
? `${entry.volume.opportunities} tries`
: `${entry.volume.opportunities} of ${entry.volume.minOpportunities} tries`,
},
{
key: 'accuracy',
label: 'Accuracy',
met: entry.accuracy.met,
detail:
entry.accuracy.windowFilled < entry.accuracy.windowSize
? `${entry.accuracy.windowFilled} of ${entry.accuracy.windowSize} recent tries`
: `${pct(entry.accuracy.recentAccuracy)} over last ${entry.accuracy.windowSize}, need ${pct(entry.accuracy.minAccuracy)}`,
},
]
if (entry.speed.rule !== 'off') {
dims.push({
key: 'speed',
label: 'Speed',
met: entry.speed.met,
detail:
entry.speed.secondsPerTerm == null
? 'not enough timed tries'
: `${entry.speed.secondsPerTerm.toFixed(1)}s per step, need under ${entry.speed.maxSecondsPerTerm}s`,
})
}
return dims
}
/** The one thing to say next to a skill name: "ready" or the first unmet dimension. */
export function summarizeLinearEntry(entry: LinearEntryAssessment): string {
if (entry.ready) return 'ready'
const blocker = describeLinearEntry(entry).find((d) => !d.met)
return blocker ? `${blocker.label.toLowerCase()}: ${blocker.detail}` : 'ready'
}
export function LinearEntryReport({
entry,
variant = 'full',
}: {
entry: LinearEntryAssessment
variant?: 'full' | 'compact'
}) {
if (variant === 'compact') {
return (
<span
data-element="linear-entry-summary"
data-ready={entry.ready}
className={css({
fontSize: '0.75rem',
color: entry.ready ? 'green.600' : 'gray.500',
_dark: { color: entry.ready ? 'green.300' : 'gray.400' },
})}
>
{entry.ready ? '✓ ' : ''}
{summarizeLinearEntry(entry)}
</span>
)
}
return (
<ul
data-element="linear-entry-report"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
fontSize: '0.75rem',
color: 'gray.600',
_dark: { color: 'gray.300' },
})}
>
{describeLinearEntry(entry).map((d) => (
<li key={d.key} data-dimension={d.key} data-met={d.met}>
<span aria-hidden>{d.met ? '✓' : '○'} </span>
<strong>{d.label}</strong> · {d.detail}
</li>
))}
</ul>
)
}
|