feat(practice): add student onboarding and offline sync features

Add three onboarding features for students coming from books/tutors:

1. Placement Test - Adaptive diagnostic quiz with configurable thresholds
   - Tests skills progressively following curriculum order
   - Tracks consecutive correct/wrong to determine mastery level
   - Presets: Quick, Standard, Thorough assessment modes
   - Shows real-time progress and skill-by-skill results

2. Manual Skill Selector - Teacher-controlled skill mastery setting
   - SAI Abacus Mind Math book level presets (Level 1, 2, 3)
   - Accordion UI organized by skill category
   - Checkbox selection for individual skills

3. Offline Session Form - Record practice done outside the app
   - Date picker, problem count, accuracy slider
   - Skill focus dropdown and notes field

Includes Storybook stories for all new components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-06 14:04:17 -06:00
parent 585543809a
commit b52f0547af
9 changed files with 2962 additions and 0 deletions

View File

@@ -11,6 +11,12 @@ import {
StudentSelector,
type StudentWithProgress,
} from '@/components/practice'
import { ManualSkillSelector } from '@/components/practice/ManualSkillSelector'
import {
type OfflineSessionData,
OfflineSessionForm,
} from '@/components/practice/OfflineSessionForm'
import { PlacementTest } from '@/components/practice/PlacementTest'
import type { SlotResult } from '@/db/schema/session-plans'
import { usePlayerCurriculum } from '@/hooks/usePlayerCurriculum'
import {
@@ -61,6 +67,7 @@ type ViewState =
| 'practicing'
| 'summary'
| 'creating'
| 'placement-test'
interface SessionConfig {
durationMinutes: number
@@ -84,6 +91,10 @@ export default function PracticePage() {
durationMinutes: 10,
})
// Modal states for onboarding features
const [showManualSkillModal, setShowManualSkillModal] = useState(false)
const [showOfflineSessionModal, setShowOfflineSessionModal] = useState(false)
// React Query hooks for players
const { data: players = [], isLoading: isLoadingStudents } = useUserPlayers()
@@ -288,6 +299,59 @@ export default function PracticePage() {
window.location.href = '/create/worksheets/addition'
}, [])
// Handle opening placement test
const handleRunPlacementTest = useCallback(() => {
setViewState('placement-test')
}, [])
// Handle placement test completion
const handlePlacementTestComplete = useCallback(
(results: {
masteredSkillIds: string[]
practicingSkillIds: string[]
totalProblems: number
totalCorrect: number
}) => {
// TODO: Save results to curriculum via API
console.log('Placement test complete:', results)
// Return to dashboard after completion
setViewState('dashboard')
},
[]
)
// Handle placement test cancel
const handlePlacementTestCancel = useCallback(() => {
setViewState('dashboard')
}, [])
// Handle opening manual skill selector
const handleSetSkillsManually = useCallback(() => {
setShowManualSkillModal(true)
}, [])
// Handle saving manual skill selections
const handleSaveManualSkills = useCallback(async (masteredSkillIds: string[]): Promise<void> => {
// TODO: Save skills to curriculum via API
console.log('Manual skills saved:', masteredSkillIds)
setShowManualSkillModal(false)
}, [])
// Handle opening offline session form
const handleRecordOfflinePractice = useCallback(() => {
setShowOfflineSessionModal(true)
}, [])
// Handle submitting offline session
const handleSubmitOfflineSession = useCallback(
async (data: OfflineSessionData): Promise<void> => {
// TODO: Save offline session to database via API
console.log('Offline session recorded:', data)
setShowOfflineSessionModal(false)
},
[]
)
// Build current phase info from curriculum
const currentPhase = curriculum.curriculum
? getPhaseInfo(curriculum.curriculum.currentPhaseId)
@@ -406,6 +470,9 @@ export default function PracticePage() {
onViewFullProgress={handleViewFullProgress}
onGenerateWorksheet={handleGenerateWorksheet}
onChangeStudent={handleChangeStudent}
onRunPlacementTest={handleRunPlacementTest}
onSetSkillsManually={handleSetSkillsManually}
onRecordOfflinePractice={handleRecordOfflinePractice}
/>
)}
@@ -738,7 +805,38 @@ export default function PracticePage() {
</button>
</div>
)}
{viewState === 'placement-test' && selectedStudent && (
<PlacementTest
studentName={selectedStudent.name}
playerId={selectedStudent.id}
onComplete={handlePlacementTestComplete}
onCancel={handlePlacementTestCancel}
/>
)}
</div>
{/* Manual Skill Selector Modal */}
{selectedStudent && (
<ManualSkillSelector
studentName={selectedStudent.name}
playerId={selectedStudent.id}
open={showManualSkillModal}
onClose={() => setShowManualSkillModal(false)}
onSave={handleSaveManualSkills}
/>
)}
{/* Offline Session Form Modal */}
{selectedStudent && (
<OfflineSessionForm
studentName={selectedStudent.name}
playerId={selectedStudent.id}
open={showOfflineSessionModal}
onClose={() => setShowOfflineSessionModal(false)}
onSubmit={handleSubmitOfflineSession}
/>
)}
</main>
)
}

View File

@@ -0,0 +1,163 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { css } from '../../../styled-system/css'
import { ManualSkillSelector } from './ManualSkillSelector'
const meta: Meta<typeof ManualSkillSelector> = {
title: 'Practice/ManualSkillSelector',
component: ManualSkillSelector,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof ManualSkillSelector>
/**
* Interactive demo with open/close functionality
*/
function InteractiveDemo({
studentName,
currentMasteredSkills = [],
}: {
studentName: string
currentMasteredSkills?: string[]
}) {
const [isOpen, setIsOpen] = useState(true)
const [savedSkills, setSavedSkills] = useState<string[]>(currentMasteredSkills)
const handleSave = async (masteredSkillIds: string[]) => {
console.log('Saving skills:', masteredSkillIds)
setSavedSkills(masteredSkillIds)
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 500))
}
return (
<div>
<ManualSkillSelector
open={isOpen}
onClose={() => setIsOpen(false)}
studentName={studentName}
playerId="player-123"
currentMasteredSkills={savedSkills}
onSave={handleSave}
/>
{!isOpen && (
<div className={css({ textAlign: 'center' })}>
<p className={css({ mb: '3', color: 'gray.600' })}>
Saved {savedSkills.length} skills as mastered
</p>
<button
onClick={() => setIsOpen(true)}
className={css({
px: '4',
py: '2',
bg: 'blue.500',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
})}
>
Reopen Modal
</button>
</div>
)}
</div>
)
}
export const Default: Story = {
render: () => <InteractiveDemo studentName="Sonia" />,
}
export const WithExistingSkills: Story = {
render: () => (
<InteractiveDemo
studentName="Marcus"
currentMasteredSkills={[
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
]}
/>
),
}
export const Level1Complete: Story = {
render: () => (
<InteractiveDemo
studentName="Luna"
currentMasteredSkills={[
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'basic.directSubtraction',
'basic.heavenBeadSubtraction',
'basic.simpleCombinationsSub',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
'fiveComplementsSub.-4=-5+1',
'fiveComplementsSub.-3=-5+2',
'fiveComplementsSub.-2=-5+3',
'fiveComplementsSub.-1=-5+4',
]}
/>
),
}
export const AllSkillsMastered: Story = {
render: () => (
<InteractiveDemo
studentName="Expert Student"
currentMasteredSkills={[
// Basic
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'basic.directSubtraction',
'basic.heavenBeadSubtraction',
'basic.simpleCombinationsSub',
// Five complements
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
'fiveComplementsSub.-4=-5+1',
'fiveComplementsSub.-3=-5+2',
'fiveComplementsSub.-2=-5+3',
'fiveComplementsSub.-1=-5+4',
// Ten complements
'tenComplements.9=10-1',
'tenComplements.8=10-2',
'tenComplements.7=10-3',
'tenComplements.6=10-4',
'tenComplements.5=10-5',
'tenComplements.4=10-6',
'tenComplements.3=10-7',
'tenComplements.2=10-8',
'tenComplements.1=10-9',
'tenComplementsSub.-9=+1-10',
'tenComplementsSub.-8=+2-10',
'tenComplementsSub.-7=+3-10',
'tenComplementsSub.-6=+4-10',
'tenComplementsSub.-5=+5-10',
'tenComplementsSub.-4=+6-10',
'tenComplementsSub.-3=+7-10',
'tenComplementsSub.-2=+8-10',
'tenComplementsSub.-1=+9-10',
]}
/>
),
}
export const NewStudent: Story = {
render: () => <InteractiveDemo studentName="New Learner" currentMasteredSkills={[]} />,
}

View File

@@ -0,0 +1,624 @@
'use client'
import { useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import * as Accordion from '@radix-ui/react-accordion'
import { css } from '../../../styled-system/css'
/**
* Skill categories and their human-readable names
*/
const SKILL_CATEGORIES = {
basic: {
name: 'Basic Skills',
skills: {
directAddition: 'Direct Addition (1-4)',
heavenBead: 'Heaven Bead (5)',
simpleCombinations: 'Simple Combinations (6-9)',
directSubtraction: 'Direct Subtraction (1-4)',
heavenBeadSubtraction: 'Heaven Bead Subtraction (5)',
simpleCombinationsSub: 'Simple Combinations Subtraction (6-9)',
},
},
fiveComplements: {
name: 'Five Complements (Addition)',
skills: {
'4=5-1': '+4 = +5 - 1',
'3=5-2': '+3 = +5 - 2',
'2=5-3': '+2 = +5 - 3',
'1=5-4': '+1 = +5 - 4',
},
},
fiveComplementsSub: {
name: 'Five Complements (Subtraction)',
skills: {
'-4=-5+1': '-4 = -5 + 1',
'-3=-5+2': '-3 = -5 + 2',
'-2=-5+3': '-2 = -5 + 3',
'-1=-5+4': '-1 = -5 + 4',
},
},
tenComplements: {
name: 'Ten Complements (Addition)',
skills: {
'9=10-1': '+9 = +10 - 1',
'8=10-2': '+8 = +10 - 2',
'7=10-3': '+7 = +10 - 3',
'6=10-4': '+6 = +10 - 4',
'5=10-5': '+5 = +10 - 5',
'4=10-6': '+4 = +10 - 6',
'3=10-7': '+3 = +10 - 7',
'2=10-8': '+2 = +10 - 8',
'1=10-9': '+1 = +10 - 9',
},
},
tenComplementsSub: {
name: 'Ten Complements (Subtraction)',
skills: {
'-9=+1-10': '-9 = +1 - 10',
'-8=+2-10': '-8 = +2 - 10',
'-7=+3-10': '-7 = +3 - 10',
'-6=+4-10': '-6 = +4 - 10',
'-5=+5-10': '-5 = +5 - 10',
'-4=+6-10': '-4 = +6 - 10',
'-3=+7-10': '-3 = +7 - 10',
'-2=+8-10': '-2 = +8 - 10',
'-1=+9-10': '-1 = +9 - 10',
},
},
} as const
type CategoryKey = keyof typeof SKILL_CATEGORIES
/**
* Book preset mappings (SAI Abacus Mind Math levels)
*/
const BOOK_PRESETS = {
'sai-level-1': {
name: 'Abacus Mind Math - Level 1',
description: 'Basic operations, no regrouping',
skills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'basic.directSubtraction',
'basic.heavenBeadSubtraction',
'basic.simpleCombinationsSub',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
'fiveComplementsSub.-4=-5+1',
'fiveComplementsSub.-3=-5+2',
'fiveComplementsSub.-2=-5+3',
'fiveComplementsSub.-1=-5+4',
],
},
'sai-level-2': {
name: 'Abacus Mind Math - Level 2',
description: 'Five complements mastered, practicing speed',
skills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'basic.directSubtraction',
'basic.heavenBeadSubtraction',
'basic.simpleCombinationsSub',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
'fiveComplementsSub.-4=-5+1',
'fiveComplementsSub.-3=-5+2',
'fiveComplementsSub.-2=-5+3',
'fiveComplementsSub.-1=-5+4',
],
},
'sai-level-3': {
name: 'Abacus Mind Math - Level 3',
description: 'Ten complements (carrying/borrowing)',
skills: [
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
'basic.directSubtraction',
'basic.heavenBeadSubtraction',
'basic.simpleCombinationsSub',
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
'fiveComplementsSub.-4=-5+1',
'fiveComplementsSub.-3=-5+2',
'fiveComplementsSub.-2=-5+3',
'fiveComplementsSub.-1=-5+4',
'tenComplements.9=10-1',
'tenComplements.8=10-2',
'tenComplements.7=10-3',
'tenComplements.6=10-4',
'tenComplements.5=10-5',
'tenComplements.4=10-6',
'tenComplements.3=10-7',
'tenComplements.2=10-8',
'tenComplements.1=10-9',
'tenComplementsSub.-9=+1-10',
'tenComplementsSub.-8=+2-10',
'tenComplementsSub.-7=+3-10',
'tenComplementsSub.-6=+4-10',
'tenComplementsSub.-5=+5-10',
'tenComplementsSub.-4=+6-10',
'tenComplementsSub.-3=+7-10',
'tenComplementsSub.-2=+8-10',
'tenComplementsSub.-1=+9-10',
],
},
} as const
export interface ManualSkillSelectorProps {
/** Whether modal is open */
open: boolean
/** Callback when modal should close */
onClose: () => void
/** Student name (for display) */
studentName: string
/** Student ID for saving */
playerId: string
/** Currently mastered skill IDs */
currentMasteredSkills?: string[]
/** Callback when save is clicked */
onSave: (masteredSkillIds: string[]) => Promise<void>
}
/**
* ManualSkillSelector - Modal for manually setting student skill mastery
*
* Allows teachers to:
* - Select which skills a student has mastered
* - Use book level presets to auto-populate
* - Adjust individual skills before saving
*/
export function ManualSkillSelector({
open,
onClose,
studentName,
playerId,
currentMasteredSkills = [],
onSave,
}: ManualSkillSelectorProps) {
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set(currentMasteredSkills))
const [isSaving, setIsSaving] = useState(false)
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
const handlePresetChange = (presetKey: string) => {
if (presetKey === '') {
// Clear all
setSelectedSkills(new Set())
return
}
const preset = BOOK_PRESETS[presetKey as keyof typeof BOOK_PRESETS]
if (preset) {
setSelectedSkills(new Set(preset.skills))
// Expand all categories to show changes
setExpandedCategories(Object.keys(SKILL_CATEGORIES))
}
}
const toggleSkill = (skillId: string) => {
setSelectedSkills((prev) => {
const next = new Set(prev)
if (next.has(skillId)) {
next.delete(skillId)
} else {
next.add(skillId)
}
return next
})
}
const toggleCategory = (category: CategoryKey) => {
const categorySkills = Object.keys(SKILL_CATEGORIES[category].skills).map(
(skill) => `${category}.${skill}`
)
const allSelected = categorySkills.every((id) => selectedSkills.has(id))
setSelectedSkills((prev) => {
const next = new Set(prev)
if (allSelected) {
// Deselect all in category
for (const id of categorySkills) {
next.delete(id)
}
} else {
// Select all in category
for (const id of categorySkills) {
next.add(id)
}
}
return next
})
}
const handleSave = async () => {
setIsSaving(true)
try {
await onSave(Array.from(selectedSkills))
onClose()
} catch (error) {
console.error('Failed to save skills:', error)
alert('Failed to save skills. Please try again.')
} finally {
setIsSaving(false)
}
}
const selectedCount = selectedSkills.size
const totalSkills = Object.values(SKILL_CATEGORIES).reduce(
(sum, cat) => sum + Object.keys(cat.skills).length,
0
)
return (
<Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Dialog.Portal>
<Dialog.Overlay
className={css({
position: 'fixed',
inset: 0,
bg: 'rgba(0, 0, 0, 0.5)',
zIndex: 50,
})}
/>
<Dialog.Content
data-component="manual-skill-selector"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bg: 'white',
borderRadius: 'xl',
boxShadow: 'xl',
p: '6',
maxWidth: '550px',
width: '90vw',
maxHeight: '85vh',
overflowY: 'auto',
zIndex: 51,
})}
>
{/* Header */}
<div className={css({ mb: '5' })}>
<Dialog.Title
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
})}
>
Set Skills for {studentName}
</Dialog.Title>
<Dialog.Description
className={css({
fontSize: 'sm',
color: 'gray.600',
mt: '1',
})}
>
Select the skills this student has already mastered. You can use a book level preset
or select individual skills.
</Dialog.Description>
</div>
{/* Book Preset Selector */}
<div className={css({ mb: '4' })}>
<label
htmlFor="preset-select"
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '2',
})}
>
Import from Book Level
</label>
<select
id="preset-select"
data-element="book-preset-select"
onChange={(e) => handlePresetChange(e.target.value)}
className={css({
width: '100%',
px: '3',
py: '2',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: 'md',
bg: 'white',
fontSize: 'sm',
cursor: 'pointer',
_focus: {
outline: 'none',
borderColor: 'blue.500',
ring: '2px',
ringColor: 'blue.500/20',
},
})}
>
<option value="">-- Select a preset --</option>
{Object.entries(BOOK_PRESETS).map(([key, preset]) => (
<option key={key} value={key}>
{preset.name} - {preset.description}
</option>
))}
</select>
</div>
{/* Selected count */}
<div
className={css({
fontSize: 'sm',
color: 'gray.600',
mb: '3',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<span>
{selectedCount} of {totalSkills} skills marked as mastered
</span>
<button
type="button"
onClick={() => setSelectedSkills(new Set())}
className={css({
fontSize: 'xs',
color: 'red.600',
bg: 'transparent',
border: 'none',
cursor: 'pointer',
_hover: { textDecoration: 'underline' },
})}
>
Clear All
</button>
</div>
{/* Skills Accordion */}
<Accordion.Root
type="multiple"
value={expandedCategories}
onValueChange={setExpandedCategories}
className={css({
border: '1px solid',
borderColor: 'gray.200',
borderRadius: 'lg',
overflow: 'hidden',
})}
>
{(
Object.entries(SKILL_CATEGORIES) as [
CategoryKey,
(typeof SKILL_CATEGORIES)[CategoryKey],
][]
).map(([categoryKey, category]) => {
const categorySkillIds = Object.keys(category.skills).map(
(skill) => `${categoryKey}.${skill}`
)
const selectedInCategory = categorySkillIds.filter((id) =>
selectedSkills.has(id)
).length
const allSelected = selectedInCategory === categorySkillIds.length
const someSelected = selectedInCategory > 0 && !allSelected
return (
<Accordion.Item
key={categoryKey}
value={categoryKey}
className={css({
borderBottom: '1px solid',
borderColor: 'gray.200',
_last: { borderBottom: 'none' },
})}
>
<Accordion.Header>
<Accordion.Trigger
className={css({
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
bg: 'gray.50',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
_hover: { bg: 'gray.100' },
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
})}
>
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected
}}
onChange={(e) => {
e.stopPropagation()
toggleCategory(categoryKey)
}}
onClick={(e) => e.stopPropagation()}
className={css({
width: '18px',
height: '18px',
cursor: 'pointer',
})}
/>
<span
className={css({
fontWeight: 'semibold',
color: 'gray.800',
})}
>
{category.name}
</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<span
className={css({
fontSize: 'xs',
color: 'gray.500',
})}
>
{selectedInCategory}/{categorySkillIds.length}
</span>
<span
className={css({
transition: 'transform 0.2s',
})}
>
{expandedCategories.includes(categoryKey) ? '▲' : '▼'}
</span>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content
className={css({
overflow: 'hidden',
bg: 'white',
})}
>
<div className={css({ p: '3' })}>
{Object.entries(category.skills).map(([skillKey, skillName]) => {
const skillId = `${categoryKey}.${skillKey}`
const isSelected = selectedSkills.has(skillId)
return (
<label
key={skillId}
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
padding: '8px 12px',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'gray.50' },
})}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSkill(skillId)}
className={css({
width: '16px',
height: '16px',
cursor: 'pointer',
})}
/>
<span
className={css({
fontSize: 'sm',
color: isSelected ? 'green.700' : 'gray.700',
fontWeight: isSelected ? 'medium' : 'normal',
})}
>
{skillName}
</span>
{isSelected && (
<span
className={css({
fontSize: 'xs',
color: 'green.600',
bg: 'green.50',
px: '2',
py: '0.5',
borderRadius: 'full',
})}
>
Mastered
</span>
)}
</label>
)
})}
</div>
</Accordion.Content>
</Accordion.Item>
)
})}
</Accordion.Root>
{/* Actions */}
<div
className={css({
display: 'flex',
gap: '3',
justifyContent: 'flex-end',
mt: '6',
})}
>
<button
type="button"
onClick={onClose}
disabled={isSaving}
data-action="cancel"
className={css({
px: '4',
py: '2',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
bg: 'transparent',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'gray.50' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={isSaving}
data-action="save-skills"
className={css({
px: '4',
py: '2',
fontSize: 'sm',
fontWeight: 'medium',
color: 'white',
bg: 'blue.600',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'blue.700' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isSaving ? 'Saving...' : 'Save Skills'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
export default ManualSkillSelector

View File

@@ -0,0 +1,175 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { css } from '../../../styled-system/css'
import { OfflineSessionForm, type OfflineSessionData } from './OfflineSessionForm'
const meta: Meta<typeof OfflineSessionForm> = {
title: 'Practice/OfflineSessionForm',
component: OfflineSessionForm,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OfflineSessionForm>
/**
* Interactive demo with open/close functionality
*/
function InteractiveDemo({ studentName }: { studentName: string }) {
const [isOpen, setIsOpen] = useState(true)
const [lastSession, setLastSession] = useState<OfflineSessionData | null>(null)
const handleSubmit = async (data: OfflineSessionData) => {
console.log('Recording session:', data)
setLastSession(data)
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 500))
}
return (
<div>
<OfflineSessionForm
open={isOpen}
onClose={() => setIsOpen(false)}
studentName={studentName}
playerId="player-123"
onSubmit={handleSubmit}
/>
{!isOpen && (
<div className={css({ textAlign: 'center', maxWidth: '400px' })}>
{lastSession && (
<div
className={css({
mb: '4',
p: '4',
bg: 'green.50',
borderRadius: 'lg',
border: '1px solid',
borderColor: 'green.200',
})}
>
<p className={css({ fontWeight: 'bold', color: 'green.700', mb: '2' })}>
Session Recorded!
</p>
<ul
className={css({
fontSize: 'sm',
color: 'green.600',
textAlign: 'left',
listStyle: 'none',
})}
>
<li>Date: {lastSession.date}</li>
<li>Problems: {lastSession.problemCount}</li>
<li>Accuracy: {Math.round(lastSession.accuracy * 100)}%</li>
<li>Focus: {lastSession.focusSkill}</li>
{lastSession.notes && <li>Notes: {lastSession.notes}</li>}
</ul>
</div>
)}
<button
onClick={() => setIsOpen(true)}
className={css({
px: '4',
py: '2',
bg: 'blue.500',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
})}
>
Record Another Session
</button>
</div>
)}
</div>
)
}
export const Default: Story = {
render: () => <InteractiveDemo studentName="Sonia" />,
}
export const DifferentStudent: Story = {
render: () => <InteractiveDemo studentName="Marcus" />,
}
/**
* Shows the form with error display for validation
*/
function ValidationDemo() {
const [isOpen, setIsOpen] = useState(true)
const handleSubmit = async (data: OfflineSessionData) => {
console.log('Would submit:', data)
await new Promise((resolve) => setTimeout(resolve, 1000))
}
return (
<div>
<OfflineSessionForm
open={isOpen}
onClose={() => setIsOpen(false)}
studentName="Test Student"
playerId="player-123"
onSubmit={handleSubmit}
/>
{!isOpen && (
<div className={css({ textAlign: 'center' })}>
<p className={css({ mb: '3', color: 'gray.600' })}>
Try entering invalid values to see validation
</p>
<button
onClick={() => setIsOpen(true)}
className={css({
px: '4',
py: '2',
bg: 'blue.500',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
})}
>
Reopen Form
</button>
</div>
)}
</div>
)
}
export const WithValidation: Story = {
render: () => <ValidationDemo />,
}
/**
* Shows the form submission flow
*/
function SubmittingDemo() {
const [isOpen, setIsOpen] = useState(true)
const handleSubmit = async (data: OfflineSessionData) => {
console.log('Submitting...', data)
// Simulate slow API
await new Promise((resolve) => setTimeout(resolve, 3000))
}
return (
<OfflineSessionForm
open={isOpen}
onClose={() => setIsOpen(false)}
studentName="Slow API Student"
playerId="player-123"
onSubmit={handleSubmit}
/>
)
}
export const SlowSubmission: Story = {
render: () => <SubmittingDemo />,
}

View File

@@ -0,0 +1,499 @@
'use client'
import { useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { css } from '../../../styled-system/css'
/**
* Skill focus options for offline sessions
*/
const SKILL_FOCUS_OPTIONS = [
{ id: 'basic', name: 'Basic Operations', description: 'Direct addition/subtraction (1-9)' },
{ id: 'fiveComplements', name: 'Five Complements', description: 'Using +5/-5 techniques' },
{ id: 'tenComplements', name: 'Ten Complements', description: 'Carrying and borrowing' },
{ id: 'mixed', name: 'Mixed Practice', description: 'All skill levels combined' },
] as const
export interface OfflineSessionData {
date: string
problemCount: number
accuracy: number
focusSkill: string
notes?: string
}
export interface OfflineSessionFormProps {
/** Whether modal is open */
open: boolean
/** Callback when modal should close */
onClose: () => void
/** Student name (for display) */
studentName: string
/** Student ID for saving */
playerId: string
/** Callback when session is recorded */
onSubmit: (data: OfflineSessionData) => Promise<void>
}
/**
* OfflineSessionForm - Modal for recording practice done outside the app
*
* Allows teachers to:
* - Record date of practice
* - Enter number of problems completed
* - Enter approximate accuracy
* - Select the primary skill focus
* - Add optional notes
*/
export function OfflineSessionForm({
open,
onClose,
studentName,
playerId,
onSubmit,
}: OfflineSessionFormProps) {
// Form state
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0])
const [problemCount, setProblemCount] = useState(20)
const [accuracy, setAccuracy] = useState(85)
const [focusSkill, setFocusSkill] = useState('basic')
const [notes, setNotes] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
if (!date) {
newErrors.date = 'Date is required'
} else {
const selectedDate = new Date(date)
const today = new Date()
today.setHours(23, 59, 59, 999)
if (selectedDate > today) {
newErrors.date = 'Date cannot be in the future'
}
}
if (problemCount < 1) {
newErrors.problemCount = 'Must complete at least 1 problem'
} else if (problemCount > 500) {
newErrors.problemCount = 'Maximum 500 problems per session'
}
if (accuracy < 0) {
newErrors.accuracy = 'Accuracy cannot be negative'
} else if (accuracy > 100) {
newErrors.accuracy = 'Accuracy cannot exceed 100%'
}
if (!focusSkill) {
newErrors.focusSkill = 'Please select a skill focus'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async () => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit({
date,
problemCount,
accuracy: accuracy / 100, // Convert to decimal
focusSkill,
notes: notes.trim() || undefined,
})
onClose()
// Reset form for next use
setProblemCount(20)
setAccuracy(85)
setNotes('')
} catch (error) {
console.error('Failed to record session:', error)
alert('Failed to record session. Please try again.')
} finally {
setIsSubmitting(false)
}
}
const estimatedCorrect = Math.round(problemCount * (accuracy / 100))
return (
<Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Dialog.Portal>
<Dialog.Overlay
className={css({
position: 'fixed',
inset: 0,
bg: 'rgba(0, 0, 0, 0.5)',
zIndex: 50,
})}
/>
<Dialog.Content
data-component="offline-session-form"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bg: 'white',
borderRadius: 'xl',
boxShadow: 'xl',
p: '6',
maxWidth: '450px',
width: '90vw',
maxHeight: '90vh',
overflowY: 'auto',
zIndex: 51,
})}
>
{/* Header */}
<div className={css({ mb: '5' })}>
<Dialog.Title
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
})}
>
Record Offline Practice
</Dialog.Title>
<Dialog.Description
className={css({
fontSize: 'sm',
color: 'gray.600',
mt: '1',
})}
>
Log {studentName}'s practice session from outside the app (book work, tutoring, etc.)
</Dialog.Description>
</div>
{/* Form */}
<div className={css({ display: 'flex', flexDirection: 'column', gap: '4' })}>
{/* Date */}
<div>
<label
htmlFor="session-date"
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '1',
})}
>
Practice Date
</label>
<input
id="session-date"
type="date"
value={date}
onChange={(e) => {
setDate(e.target.value)
setErrors((prev) => ({ ...prev, date: '' }))
}}
max={new Date().toISOString().split('T')[0]}
className={css({
width: '100%',
px: '3',
py: '2',
border: '1px solid',
borderColor: errors.date ? 'red.500' : 'gray.300',
borderRadius: 'md',
fontSize: 'sm',
_focus: {
outline: 'none',
borderColor: 'blue.500',
ring: '2px',
ringColor: 'blue.500/20',
},
})}
/>
{errors.date && (
<p className={css({ fontSize: 'xs', color: 'red.600', mt: '1' })}>{errors.date}</p>
)}
</div>
{/* Problem Count */}
<div>
<label
htmlFor="problem-count"
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '1',
})}
>
Number of Problems
</label>
<input
id="problem-count"
type="number"
min={1}
max={500}
value={problemCount}
onChange={(e) => {
setProblemCount(Number.parseInt(e.target.value) || 0)
setErrors((prev) => ({ ...prev, problemCount: '' }))
}}
className={css({
width: '100%',
px: '3',
py: '2',
border: '1px solid',
borderColor: errors.problemCount ? 'red.500' : 'gray.300',
borderRadius: 'md',
fontSize: 'sm',
_focus: {
outline: 'none',
borderColor: 'blue.500',
ring: '2px',
ringColor: 'blue.500/20',
},
})}
/>
{errors.problemCount && (
<p className={css({ fontSize: 'xs', color: 'red.600', mt: '1' })}>
{errors.problemCount}
</p>
)}
</div>
{/* Accuracy Slider */}
<div>
<label
htmlFor="accuracy"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '1',
})}
>
<span>Accuracy</span>
<span
className={css({
fontWeight: 'bold',
color: accuracy >= 85 ? 'green.600' : accuracy >= 70 ? 'yellow.600' : 'red.600',
})}
>
{accuracy}%
</span>
</label>
<input
id="accuracy"
type="range"
min={0}
max={100}
step={5}
value={accuracy}
onChange={(e) => {
setAccuracy(Number.parseInt(e.target.value))
setErrors((prev) => ({ ...prev, accuracy: '' }))
}}
className={css({
width: '100%',
cursor: 'pointer',
})}
/>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
fontSize: 'xs',
color: 'gray.500',
})}
>
<span>0%</span>
<span>
~{estimatedCorrect} of {problemCount} correct
</span>
<span>100%</span>
</div>
{errors.accuracy && (
<p className={css({ fontSize: 'xs', color: 'red.600', mt: '1' })}>
{errors.accuracy}
</p>
)}
</div>
{/* Skill Focus */}
<div>
<label
htmlFor="focus-skill"
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '1',
})}
>
Primary Skill Focus
</label>
<select
id="focus-skill"
value={focusSkill}
onChange={(e) => {
setFocusSkill(e.target.value)
setErrors((prev) => ({ ...prev, focusSkill: '' }))
}}
className={css({
width: '100%',
px: '3',
py: '2',
border: '1px solid',
borderColor: errors.focusSkill ? 'red.500' : 'gray.300',
borderRadius: 'md',
bg: 'white',
fontSize: 'sm',
cursor: 'pointer',
_focus: {
outline: 'none',
borderColor: 'blue.500',
ring: '2px',
ringColor: 'blue.500/20',
},
})}
>
{SKILL_FOCUS_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{option.name} - {option.description}
</option>
))}
</select>
{errors.focusSkill && (
<p className={css({ fontSize: 'xs', color: 'red.600', mt: '1' })}>
{errors.focusSkill}
</p>
)}
</div>
{/* Notes */}
<div>
<label
htmlFor="notes"
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '1',
})}
>
Notes (optional)
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any observations about the session..."
rows={3}
className={css({
width: '100%',
px: '3',
py: '2',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: 'md',
fontSize: 'sm',
resize: 'vertical',
_focus: {
outline: 'none',
borderColor: 'blue.500',
ring: '2px',
ringColor: 'blue.500/20',
},
})}
/>
</div>
{/* Summary Box */}
<div
className={css({
p: '3',
bg: 'blue.50',
borderRadius: 'md',
border: '1px solid',
borderColor: 'blue.100',
})}
>
<p className={css({ fontSize: 'sm', color: 'blue.800' })}>
This will record <strong>{problemCount} problems</strong> at{' '}
<strong>{accuracy}% accuracy</strong> (~{estimatedCorrect} correct) for{' '}
<strong>{studentName}</strong> on{' '}
<strong>{new Date(date).toLocaleDateString()}</strong>.
</p>
</div>
</div>
{/* Actions */}
<div
className={css({
display: 'flex',
gap: '3',
justifyContent: 'flex-end',
mt: '6',
})}
>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
data-action="cancel"
className={css({
px: '4',
py: '2',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
bg: 'transparent',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'gray.50' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
data-action="record-session"
className={css({
px: '4',
py: '2',
fontSize: 'sm',
fontWeight: 'medium',
color: 'white',
bg: 'green.600',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'green.700' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isSubmitting ? 'Recording...' : 'Record Session'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
export default OfflineSessionForm

View File

@@ -0,0 +1,186 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { css } from '../../../styled-system/css'
import { PlacementTest } from './PlacementTest'
import { THRESHOLD_PRESETS } from '@/lib/curriculum/placement-test'
const meta: Meta<typeof PlacementTest> = {
title: 'Practice/PlacementTest',
component: PlacementTest,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof PlacementTest>
/**
* Wrapper for consistent styling
*/
function TestWrapper({ children }: { children: React.ReactNode }) {
return (
<div
className={css({
backgroundColor: 'gray.100',
minHeight: '100vh',
padding: '1rem',
})}
>
{children}
</div>
)
}
/**
* Interactive demo with complete flow
*/
function InteractiveDemo({ studentName }: { studentName: string }) {
const [results, setResults] = useState<{
masteredSkillIds: string[]
practicingSkillIds: string[]
totalProblems: number
totalCorrect: number
} | null>(null)
const [cancelled, setCancelled] = useState(false)
const handleComplete = (data: {
masteredSkillIds: string[]
practicingSkillIds: string[]
totalProblems: number
totalCorrect: number
}) => {
console.log('Test complete:', data)
setResults(data)
}
const handleCancel = () => {
console.log('Test cancelled')
setCancelled(true)
}
if (cancelled) {
return (
<TestWrapper>
<div className={css({ textAlign: 'center', py: '8' })}>
<p className={css({ fontSize: 'xl', color: 'gray.600', mb: '4' })}>
Placement test was cancelled.
</p>
<button
onClick={() => setCancelled(false)}
className={css({
px: '4',
py: '2',
bg: 'blue.500',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
})}
>
Restart Test
</button>
</div>
</TestWrapper>
)
}
if (results) {
return (
<TestWrapper>
<div
className={css({
maxWidth: '500px',
margin: '0 auto',
py: '8',
textAlign: 'center',
})}
>
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', mb: '4' })}>Results Saved!</h2>
<div
className={css({
bg: 'white',
p: '4',
borderRadius: 'lg',
boxShadow: 'md',
mb: '4',
})}
>
<p>
<strong>Mastered:</strong> {results.masteredSkillIds.length} skills
</p>
<p>
<strong>Practicing:</strong> {results.practicingSkillIds.length} skills
</p>
<p>
<strong>Accuracy:</strong>{' '}
{Math.round((results.totalCorrect / results.totalProblems) * 100)}%
</p>
</div>
<button
onClick={() => setResults(null)}
className={css({
px: '4',
py: '2',
bg: 'blue.500',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
})}
>
Run Another Test
</button>
</div>
</TestWrapper>
)
}
return (
<TestWrapper>
<PlacementTest
studentName={studentName}
playerId="player-123"
onComplete={handleComplete}
onCancel={handleCancel}
/>
</TestWrapper>
)
}
export const Default: Story = {
render: () => <InteractiveDemo studentName="Sonia" />,
}
export const DifferentStudent: Story = {
render: () => <InteractiveDemo studentName="Marcus" />,
}
export const QuickPreset: Story = {
render: () => (
<TestWrapper>
<PlacementTest
studentName="Quick Test Student"
playerId="player-123"
onComplete={(r) => console.log('Complete:', r)}
onCancel={() => console.log('Cancel')}
initialThresholds={THRESHOLD_PRESETS.quick.thresholds}
/>
</TestWrapper>
),
}
export const ThoroughPreset: Story = {
render: () => (
<TestWrapper>
<PlacementTest
studentName="Thorough Test Student"
playerId="player-123"
onComplete={(r) => console.log('Complete:', r)}
onCancel={() => console.log('Cancel')}
initialThresholds={THRESHOLD_PRESETS.thorough.thresholds}
/>
</TestWrapper>
),
}

View File

@@ -0,0 +1,665 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { css } from '../../../styled-system/css'
import {
DEFAULT_THRESHOLDS,
generateProblemForSkill,
getPlacementResults,
initializePlacementTest,
type PlacementTestState,
type PlacementThresholds,
recordAnswer,
SKILL_NAMES,
SKILL_ORDER,
THRESHOLD_PRESETS,
type PresetKey,
} from '@/lib/curriculum/placement-test'
import { NumericKeypad } from './NumericKeypad'
import { VerticalProblem } from './VerticalProblem'
export interface PlacementTestProps {
/** Student name (for display) */
studentName: string
/** Student ID for saving results */
playerId: string
/** Callback when test is complete */
onComplete: (results: {
masteredSkillIds: string[]
practicingSkillIds: string[]
totalProblems: number
totalCorrect: number
}) => void
/** Callback to cancel/exit the test */
onCancel: () => void
/** Initial thresholds (defaults to standard) */
initialThresholds?: PlacementThresholds
}
type TestPhase = 'setup' | 'testing' | 'results'
/**
* PlacementTest - Adaptive diagnostic quiz for skill assessment
*
* Features:
* - Configurable thresholds with presets
* - Progressive skill testing following curriculum order
* - Real-time feedback on answers
* - Automatic skill level determination
*/
export function PlacementTest({
studentName,
playerId,
onComplete,
onCancel,
initialThresholds = DEFAULT_THRESHOLDS,
}: PlacementTestProps) {
const [phase, setPhase] = useState<TestPhase>('setup')
const [thresholds, setThresholds] = useState<PlacementThresholds>(initialThresholds)
const [selectedPreset, setSelectedPreset] = useState<PresetKey>('standard')
const [testState, setTestState] = useState<PlacementTestState | null>(null)
const [userAnswer, setUserAnswer] = useState('')
const [showFeedback, setShowFeedback] = useState(false)
const [lastAnswerCorrect, setLastAnswerCorrect] = useState(false)
const [problemStartTime, setProblemStartTime] = useState<number>(0)
// Generate next problem when test state changes
useEffect(() => {
if (testState && !testState.isComplete && !testState.currentProblem && phase === 'testing') {
const currentSkillId = SKILL_ORDER[testState.currentSkillIndex]
const problem = generateProblemForSkill(currentSkillId)
if (problem) {
setTestState((prev) => (prev ? { ...prev, currentProblem: problem } : null))
setProblemStartTime(Date.now())
}
}
}, [testState, phase])
// Check for test completion
useEffect(() => {
if (testState?.isComplete && phase === 'testing') {
setPhase('results')
}
}, [testState?.isComplete, phase])
const handlePresetChange = (preset: PresetKey) => {
setSelectedPreset(preset)
setThresholds(THRESHOLD_PRESETS[preset].thresholds)
}
const handleStartTest = () => {
const initialState = initializePlacementTest(thresholds)
setTestState(initialState)
setPhase('testing')
}
const handleSubmitAnswer = useCallback(() => {
if (!testState || !testState.currentProblem || showFeedback) return
const answer = Number.parseInt(userAnswer)
const isCorrect = answer === testState.currentProblem.answer
setLastAnswerCorrect(isCorrect)
setShowFeedback(true)
// Show feedback briefly, then advance
setTimeout(() => {
const newState = recordAnswer(testState, isCorrect)
setTestState({ ...newState, currentProblem: null })
setUserAnswer('')
setShowFeedback(false)
}, 1000)
}, [testState, userAnswer, showFeedback])
const handleDigit = useCallback(
(digit: string) => {
if (showFeedback) return
setUserAnswer((prev) => {
// Limit to reasonable answer length
if (prev.length >= 4) return prev
return prev + digit
})
},
[showFeedback]
)
const handleBackspace = useCallback(() => {
if (showFeedback) return
setUserAnswer((prev) => prev.slice(0, -1))
}, [showFeedback])
const handleKeypadSubmit = useCallback(() => {
if (showFeedback || !userAnswer) return
handleSubmitAnswer()
}, [showFeedback, userAnswer, handleSubmitAnswer])
const handleFinish = () => {
if (!testState) return
const results = getPlacementResults(testState)
onComplete({
masteredSkillIds: results.masteredSkills,
practicingSkillIds: results.practicingSkills,
totalProblems: results.totalProblems,
totalCorrect: results.totalCorrect,
})
}
// Setup phase - configure thresholds
if (phase === 'setup') {
return (
<div
data-component="placement-test"
data-phase="setup"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2rem',
padding: '2rem',
maxWidth: '500px',
margin: '0 auto',
})}
>
<h1
className={css({
fontSize: '1.75rem',
fontWeight: 'bold',
color: 'gray.800',
textAlign: 'center',
})}
>
Placement Test for {studentName}
</h1>
<p
className={css({
fontSize: '1rem',
color: 'gray.600',
textAlign: 'center',
})}
>
This test will determine which abacus skills {studentName} has already mastered.
</p>
{/* Preset selector */}
<div className={css({ width: '100%' })}>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '2',
})}
>
Test Intensity
</label>
<div className={css({ display: 'flex', gap: '2' })}>
{(
Object.entries(THRESHOLD_PRESETS) as [
PresetKey,
(typeof THRESHOLD_PRESETS)[PresetKey],
][]
).map(([key, preset]) => (
<button
key={key}
type="button"
onClick={() => handlePresetChange(key)}
className={css({
flex: 1,
py: '3',
px: '3',
borderRadius: 'lg',
border: '2px solid',
borderColor: selectedPreset === key ? 'blue.500' : 'gray.200',
bg: selectedPreset === key ? 'blue.50' : 'white',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: selectedPreset === key ? 'blue.500' : 'gray.300',
},
})}
>
<div
className={css({
fontWeight: 'bold',
color: selectedPreset === key ? 'blue.700' : 'gray.800',
fontSize: 'sm',
})}
>
{preset.name}
</div>
<div
className={css({
fontSize: 'xs',
color: selectedPreset === key ? 'blue.600' : 'gray.500',
mt: '1',
})}
>
{preset.description}
</div>
</button>
))}
</div>
</div>
{/* Threshold details */}
<div
className={css({
width: '100%',
p: '4',
bg: 'gray.50',
borderRadius: 'lg',
border: '1px solid',
borderColor: 'gray.200',
})}
>
<h3
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '2',
})}
>
Current Settings
</h3>
<ul
className={css({
fontSize: 'sm',
color: 'gray.600',
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: '1',
})}
>
<li>Practicing: {thresholds.practicingConsecutive} consecutive correct</li>
<li>
Mastered: {thresholds.masteredTotal} correct at{' '}
{Math.round(thresholds.masteredAccuracy * 100)}% accuracy
</li>
<li>Stop testing: {thresholds.stopOnWrong} consecutive wrong</li>
</ul>
</div>
{/* Action buttons */}
<div className={css({ display: 'flex', gap: '3', width: '100%' })}>
<button
type="button"
onClick={onCancel}
className={css({
flex: 1,
py: '3',
fontSize: '1rem',
fontWeight: 'medium',
color: 'gray.700',
bg: 'gray.100',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: { bg: 'gray.200' },
})}
>
Cancel
</button>
<button
type="button"
onClick={handleStartTest}
data-action="start-test"
className={css({
flex: 2,
py: '3',
fontSize: '1rem',
fontWeight: 'bold',
color: 'white',
bg: 'blue.500',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: { bg: 'blue.600' },
})}
>
Start Test
</button>
</div>
</div>
)
}
// Testing phase
if (phase === 'testing' && testState) {
const currentSkillId = SKILL_ORDER[testState.currentSkillIndex]
const currentSkillState = testState.skillStates.get(currentSkillId)
const skillName = SKILL_NAMES[currentSkillId] || currentSkillId
// Calculate progress
const completedSkills = Array.from(testState.skillStates.values()).filter(
(s) => s.status !== 'pending' && s.status !== 'testing'
).length
const progressPercent = Math.round((completedSkills / SKILL_ORDER.length) * 100)
return (
<div
data-component="placement-test"
data-phase="testing"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1.5rem',
padding: '1.5rem',
maxWidth: '600px',
margin: '0 auto',
})}
>
{/* Header with progress */}
<div className={css({ width: '100%' })}>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: '2',
})}
>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>Testing: {skillName}</span>
<span className={css({ fontSize: 'sm', color: 'gray.500' })}>
{testState.problemsAnswered} problems answered
</span>
</div>
<div
className={css({
width: '100%',
height: '8px',
bg: 'gray.200',
borderRadius: '4px',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
bg: 'blue.500',
transition: 'width 0.3s ease',
})}
style={{ width: `${progressPercent}%` }}
/>
</div>
{currentSkillState && (
<div
className={css({
display: 'flex',
gap: '3',
mt: '2',
fontSize: 'xs',
color: 'gray.500',
})}
>
<span>
Skill: {currentSkillState.correct}/{currentSkillState.attempts} correct
</span>
<span>Streak: {currentSkillState.consecutive}</span>
</div>
)}
</div>
{/* Problem display */}
{testState.currentProblem && (
<div className={css({ my: '2' })}>
<VerticalProblem
terms={testState.currentProblem.terms}
userAnswer={userAnswer}
isFocused={!showFeedback}
isCompleted={showFeedback}
correctAnswer={testState.currentProblem.answer}
size="large"
/>
</div>
)}
{/* Feedback overlay */}
{showFeedback && (
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: lastAnswerCorrect ? 'green.600' : 'red.600',
animation: 'pulse 0.5s ease-in-out',
})}
>
{lastAnswerCorrect ? 'Correct!' : `${testState.currentProblem?.answer}`}
</div>
)}
{/* Keypad */}
{!showFeedback && (
<NumericKeypad
onDigit={handleDigit}
onBackspace={handleBackspace}
onSubmit={handleKeypadSubmit}
disabled={showFeedback || !testState.currentProblem}
/>
)}
{/* Skip/Cancel button */}
<button
type="button"
onClick={onCancel}
className={css({
mt: '2',
fontSize: 'sm',
color: 'gray.500',
bg: 'transparent',
border: 'none',
cursor: 'pointer',
_hover: { color: 'gray.700', textDecoration: 'underline' },
})}
>
End Test Early
</button>
</div>
)
}
// Results phase
if (phase === 'results' && testState) {
const results = getPlacementResults(testState)
return (
<div
data-component="placement-test"
data-phase="results"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2rem',
padding: '2rem',
maxWidth: '550px',
margin: '0 auto',
})}
>
<div className={css({ textAlign: 'center' })}>
<h1
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: 'gray.800',
mb: '2',
})}
>
Placement Complete!
</h1>
<p className={css({ fontSize: '1.25rem', color: 'blue.600' })}>
{studentName} placed at: <strong>{results.suggestedLevel}</strong>
</p>
</div>
{/* Stats summary */}
<div
className={css({
display: 'flex',
gap: '3',
width: '100%',
})}
>
<div
className={css({
flex: 1,
p: '3',
bg: 'green.50',
borderRadius: 'lg',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: 'green.700',
})}
>
{results.masteredSkills.length}
</div>
<div className={css({ fontSize: 'sm', color: 'green.600' })}>Skills Mastered</div>
</div>
<div
className={css({
flex: 1,
p: '3',
bg: 'yellow.50',
borderRadius: 'lg',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: 'yellow.700',
})}
>
{results.practicingSkills.length}
</div>
<div className={css({ fontSize: 'sm', color: 'yellow.600' })}>Skills Practicing</div>
</div>
<div
className={css({
flex: 1,
p: '3',
bg: 'blue.50',
borderRadius: 'lg',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color: 'blue.700',
})}
>
{Math.round(results.overallAccuracy * 100)}%
</div>
<div className={css({ fontSize: 'sm', color: 'blue.600' })}>Accuracy</div>
</div>
</div>
{/* Skill lists */}
{results.masteredSkills.length > 0 && (
<div className={css({ width: '100%' })}>
<h3
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '2',
})}
>
Mastered Skills
</h3>
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '2' })}>
{results.masteredSkills.map((skillId) => (
<span
key={skillId}
className={css({
px: '3',
py: '1',
bg: 'green.100',
color: 'green.700',
borderRadius: 'full',
fontSize: 'xs',
fontWeight: 'medium',
})}
>
{SKILL_NAMES[skillId] || skillId}
</span>
))}
</div>
</div>
)}
{results.practicingSkills.length > 0 && (
<div className={css({ width: '100%' })}>
<h3
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.700',
mb: '2',
})}
>
Practicing Skills
</h3>
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '2' })}>
{results.practicingSkills.map((skillId) => (
<span
key={skillId}
className={css({
px: '3',
py: '1',
bg: 'yellow.100',
color: 'yellow.700',
borderRadius: 'full',
fontSize: 'xs',
fontWeight: 'medium',
})}
>
{SKILL_NAMES[skillId] || skillId}
</span>
))}
</div>
</div>
)}
{/* Action button */}
<button
type="button"
onClick={handleFinish}
data-action="save-results"
className={css({
width: '100%',
py: '3',
fontSize: '1.125rem',
fontWeight: 'bold',
color: 'white',
bg: 'green.500',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
_hover: { bg: 'green.600' },
})}
>
Save Results & Continue
</button>
</div>
)
}
return null
}
export default PlacementTest

View File

@@ -37,6 +37,12 @@ interface ProgressDashboardProps {
onViewFullProgress: () => void
onGenerateWorksheet: () => void
onChangeStudent: () => void
/** Callback to run placement test */
onRunPlacementTest?: () => void
/** Callback to manually set skills */
onSetSkillsManually?: () => void
/** Callback to record offline practice */
onRecordOfflinePractice?: () => void
}
/**
@@ -71,6 +77,9 @@ export function ProgressDashboard({
onViewFullProgress,
onGenerateWorksheet,
onChangeStudent,
onRunPlacementTest,
onSetSkillsManually,
onRecordOfflinePractice,
}: ProgressDashboardProps) {
const progressPercent =
currentPhase.totalSkills > 0
@@ -308,6 +317,112 @@ export function ProgressDashboard({
</div>
</div>
{/* Onboarding & Assessment Tools */}
{(onRunPlacementTest || onSetSkillsManually || onRecordOfflinePractice) && (
<div
data-section="onboarding-tools"
className={css({
width: '100%',
padding: '1rem',
backgroundColor: 'gray.50',
borderRadius: '12px',
border: '1px solid',
borderColor: 'gray.200',
})}
>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: 'semibold',
color: 'gray.600',
marginBottom: '0.75rem',
})}
>
Assessment & Sync
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
{onRunPlacementTest && (
<button
type="button"
data-action="run-placement-test"
onClick={onRunPlacementTest}
className={css({
padding: '0.5rem 0.75rem',
fontSize: '0.875rem',
color: 'blue.700',
backgroundColor: 'blue.50',
borderRadius: '6px',
border: '1px solid',
borderColor: 'blue.200',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: 'blue.100',
borderColor: 'blue.300',
},
})}
>
Placement Test
</button>
)}
{onSetSkillsManually && (
<button
type="button"
data-action="set-skills-manually"
onClick={onSetSkillsManually}
className={css({
padding: '0.5rem 0.75rem',
fontSize: '0.875rem',
color: 'purple.700',
backgroundColor: 'purple.50',
borderRadius: '6px',
border: '1px solid',
borderColor: 'purple.200',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: 'purple.100',
borderColor: 'purple.300',
},
})}
>
Set Skills Manually
</button>
)}
{onRecordOfflinePractice && (
<button
type="button"
data-action="record-offline-practice"
onClick={onRecordOfflinePractice}
className={css({
padding: '0.5rem 0.75rem',
fontSize: '0.875rem',
color: 'green.700',
backgroundColor: 'green.50',
borderRadius: '6px',
border: '1px solid',
borderColor: 'green.200',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: 'green.100',
borderColor: 'green.300',
},
})}
>
Record Offline Practice
</button>
)}
</div>
</div>
)}
{/* Recent skills (if available) */}
{recentSkills.length > 0 && (
<div

View File

@@ -0,0 +1,437 @@
import type { SkillSet } from '@/types/tutorial'
import { createBasicSkillSet } from '@/types/tutorial'
import { generateSingleProblem, type ProblemConstraints } from '@/utils/problemGenerator'
/**
* Configurable thresholds for placement test
*/
export interface PlacementThresholds {
/** Consecutive correct answers to mark skill as "practicing" */
practicingConsecutive: number
/** Total correct answers needed for "mastered" */
masteredTotal: number
/** Minimum accuracy (0-1) for "mastered" */
masteredAccuracy: number
/** Consecutive wrong answers to stop testing a skill */
stopOnWrong: number
}
/**
* Preset threshold configurations
*/
export const THRESHOLD_PRESETS = {
quick: {
name: 'Quick Assessment',
description: 'Fewer problems, faster results',
thresholds: {
practicingConsecutive: 2,
masteredTotal: 3,
masteredAccuracy: 0.8,
stopOnWrong: 2,
} as PlacementThresholds,
},
standard: {
name: 'Standard',
description: 'Balanced assessment',
thresholds: {
practicingConsecutive: 3,
masteredTotal: 5,
masteredAccuracy: 0.85,
stopOnWrong: 2,
} as PlacementThresholds,
},
thorough: {
name: 'Thorough',
description: 'More confidence, more problems',
thresholds: {
practicingConsecutive: 4,
masteredTotal: 7,
masteredAccuracy: 0.9,
stopOnWrong: 3,
} as PlacementThresholds,
},
} as const
export type PresetKey = keyof typeof THRESHOLD_PRESETS
export const DEFAULT_THRESHOLDS = THRESHOLD_PRESETS.standard.thresholds
/**
* Order of skills to test (follows curriculum progression)
*/
export const SKILL_ORDER = [
// Basic addition
'basic.directAddition',
'basic.heavenBead',
'basic.simpleCombinations',
// Basic subtraction
'basic.directSubtraction',
'basic.heavenBeadSubtraction',
'basic.simpleCombinationsSub',
// Five complements addition
'fiveComplements.4=5-1',
'fiveComplements.3=5-2',
'fiveComplements.2=5-3',
'fiveComplements.1=5-4',
// Five complements subtraction
'fiveComplementsSub.-4=-5+1',
'fiveComplementsSub.-3=-5+2',
'fiveComplementsSub.-2=-5+3',
'fiveComplementsSub.-1=-5+4',
// Ten complements addition
'tenComplements.9=10-1',
'tenComplements.8=10-2',
'tenComplements.7=10-3',
'tenComplements.6=10-4',
'tenComplements.5=10-5',
'tenComplements.4=10-6',
'tenComplements.3=10-7',
'tenComplements.2=10-8',
'tenComplements.1=10-9',
// Ten complements subtraction
'tenComplementsSub.-9=+1-10',
'tenComplementsSub.-8=+2-10',
'tenComplementsSub.-7=+3-10',
'tenComplementsSub.-6=+4-10',
'tenComplementsSub.-5=+5-10',
'tenComplementsSub.-4=+6-10',
'tenComplementsSub.-3=+7-10',
'tenComplementsSub.-2=+8-10',
'tenComplementsSub.-1=+9-10',
] as const
/**
* Human-readable skill names
*/
export const SKILL_NAMES: Record<string, string> = {
'basic.directAddition': 'Direct Addition (1-4)',
'basic.heavenBead': 'Heaven Bead (5)',
'basic.simpleCombinations': 'Simple Combinations (6-9)',
'basic.directSubtraction': 'Direct Subtraction (1-4)',
'basic.heavenBeadSubtraction': 'Heaven Bead Subtraction (5)',
'basic.simpleCombinationsSub': 'Simple Combinations Subtraction (6-9)',
'fiveComplements.4=5-1': '+4 = +5 - 1',
'fiveComplements.3=5-2': '+3 = +5 - 2',
'fiveComplements.2=5-3': '+2 = +5 - 3',
'fiveComplements.1=5-4': '+1 = +5 - 4',
'fiveComplementsSub.-4=-5+1': '-4 = -5 + 1',
'fiveComplementsSub.-3=-5+2': '-3 = -5 + 2',
'fiveComplementsSub.-2=-5+3': '-2 = -5 + 3',
'fiveComplementsSub.-1=-5+4': '-1 = -5 + 4',
'tenComplements.9=10-1': '+9 = +10 - 1',
'tenComplements.8=10-2': '+8 = +10 - 2',
'tenComplements.7=10-3': '+7 = +10 - 3',
'tenComplements.6=10-4': '+6 = +10 - 4',
'tenComplements.5=10-5': '+5 = +10 - 5',
'tenComplements.4=10-6': '+4 = +10 - 6',
'tenComplements.3=10-7': '+3 = +10 - 7',
'tenComplements.2=10-8': '+2 = +10 - 8',
'tenComplements.1=10-9': '+1 = +10 - 9',
'tenComplementsSub.-9=+1-10': '-9 = +1 - 10',
'tenComplementsSub.-8=+2-10': '-8 = +2 - 10',
'tenComplementsSub.-7=+3-10': '-7 = +3 - 10',
'tenComplementsSub.-6=+4-10': '-6 = +4 - 10',
'tenComplementsSub.-5=+5-10': '-5 = +5 - 10',
'tenComplementsSub.-4=+6-10': '-4 = +6 - 10',
'tenComplementsSub.-3=+7-10': '-3 = +7 - 10',
'tenComplementsSub.-2=+8-10': '-2 = +8 - 10',
'tenComplementsSub.-1=+9-10': '-1 = +9 - 10',
}
/**
* State of testing for a single skill
*/
export interface SkillTestState {
skillId: string
attempts: number
correct: number
consecutive: number
consecutiveWrong: number
status: 'pending' | 'testing' | 'mastered' | 'practicing' | 'stopped'
}
/**
* Overall placement test state
*/
export interface PlacementTestState {
/** Current skill index in SKILL_ORDER */
currentSkillIndex: number
/** Test state for each skill */
skillStates: Map<string, SkillTestState>
/** Problems answered so far */
problemsAnswered: number
/** Total correct answers */
totalCorrect: number
/** Thresholds being used */
thresholds: PlacementThresholds
/** Whether the test is complete */
isComplete: boolean
/** Current problem (if testing) */
currentProblem: { terms: number[]; answer: number } | null
}
/**
* Initialize placement test state
*/
export function initializePlacementTest(
thresholds: PlacementThresholds = DEFAULT_THRESHOLDS
): PlacementTestState {
const skillStates = new Map<string, SkillTestState>()
for (const skillId of SKILL_ORDER) {
skillStates.set(skillId, {
skillId,
attempts: 0,
correct: 0,
consecutive: 0,
consecutiveWrong: 0,
status: 'pending',
})
}
// Start testing the first skill
const firstSkillState = skillStates.get(SKILL_ORDER[0])
if (firstSkillState) {
firstSkillState.status = 'testing'
}
return {
currentSkillIndex: 0,
skillStates,
problemsAnswered: 0,
totalCorrect: 0,
thresholds,
isComplete: false,
currentProblem: null,
}
}
/**
* Get skill set that enables all skills up to and including the current skill
*/
function getSkillSetForTesting(currentSkillId: string): SkillSet {
const skillSet = createBasicSkillSet()
const currentIndex = SKILL_ORDER.indexOf(currentSkillId as (typeof SKILL_ORDER)[number])
if (currentIndex === -1) return skillSet
// Enable all skills up to and including current
for (let i = 0; i <= currentIndex; i++) {
const skillId = SKILL_ORDER[i]
const [category, skill] = skillId.split('.')
if (category === 'basic') {
;(skillSet.basic as Record<string, boolean>)[skill] = true
} else if (category === 'fiveComplements') {
;(skillSet.fiveComplements as Record<string, boolean>)[skill] = true
} else if (category === 'fiveComplementsSub') {
;(skillSet.fiveComplementsSub as Record<string, boolean>)[skill] = true
} else if (category === 'tenComplements') {
;(skillSet.tenComplements as Record<string, boolean>)[skill] = true
} else if (category === 'tenComplementsSub') {
;(skillSet.tenComplementsSub as Record<string, boolean>)[skill] = true
}
}
return skillSet
}
/**
* Generate a problem targeting the current skill
*/
export function generateProblemForSkill(
skillId: string
): { terms: number[]; answer: number } | null {
const skillSet = getSkillSetForTesting(skillId)
// Determine if this is a ten complements skill (needs larger numbers)
const isTenComplement = skillId.startsWith('tenComplements')
const constraints: ProblemConstraints = {
numberRange: { min: 1, max: isTenComplement ? 99 : 9 },
maxTerms: 3,
problemCount: 1,
}
// Target the specific skill we're testing
const [category, skill] = skillId.split('.')
const targetSkills: Partial<SkillSet> = {}
if (category === 'basic') {
targetSkills.basic = { [skill]: true } as SkillSet['basic']
} else if (category === 'fiveComplements') {
targetSkills.fiveComplements = { [skill]: true } as SkillSet['fiveComplements']
} else if (category === 'fiveComplementsSub') {
targetSkills.fiveComplementsSub = { [skill]: true } as SkillSet['fiveComplementsSub']
} else if (category === 'tenComplements') {
targetSkills.tenComplements = { [skill]: true } as SkillSet['tenComplements']
} else if (category === 'tenComplementsSub') {
targetSkills.tenComplementsSub = { [skill]: true } as SkillSet['tenComplementsSub']
}
const problem = generateSingleProblem(constraints, skillSet, targetSkills)
if (problem) {
return { terms: problem.terms, answer: problem.answer }
}
// Fallback if generation fails
return null
}
/**
* Record an answer and update test state
*/
export function recordAnswer(state: PlacementTestState, isCorrect: boolean): PlacementTestState {
const currentSkillId = SKILL_ORDER[state.currentSkillIndex]
const skillState = state.skillStates.get(currentSkillId)
if (!skillState || skillState.status !== 'testing') {
return state
}
// Update skill stats
skillState.attempts++
if (isCorrect) {
skillState.correct++
skillState.consecutive++
skillState.consecutiveWrong = 0
} else {
skillState.consecutive = 0
skillState.consecutiveWrong++
}
// Check if skill should transition to mastered, practicing, or stopped
const { thresholds } = state
const accuracy = skillState.attempts > 0 ? skillState.correct / skillState.attempts : 0
if (skillState.correct >= thresholds.masteredTotal && accuracy >= thresholds.masteredAccuracy) {
skillState.status = 'mastered'
} else if (skillState.consecutive >= thresholds.practicingConsecutive) {
skillState.status = 'practicing'
} else if (skillState.consecutiveWrong >= thresholds.stopOnWrong) {
skillState.status = 'stopped'
}
// Update overall stats
const newState: PlacementTestState = {
...state,
problemsAnswered: state.problemsAnswered + 1,
totalCorrect: state.totalCorrect + (isCorrect ? 1 : 0),
}
// If current skill is done (mastered, practicing, or stopped), move to next
if (skillState.status !== 'testing') {
const nextSkillIndex = findNextTestableSkill(newState)
if (nextSkillIndex === -1) {
// All skills tested
newState.isComplete = true
newState.currentProblem = null
} else {
newState.currentSkillIndex = nextSkillIndex
const nextSkillState = newState.skillStates.get(SKILL_ORDER[nextSkillIndex])
if (nextSkillState) {
nextSkillState.status = 'testing'
}
}
}
return newState
}
/**
* Find the next skill that needs testing
*/
function findNextTestableSkill(state: PlacementTestState): number {
// Check if any earlier skill was stopped - if so, skip skills that depend on it
let lastStoppedCategory: string | null = null
for (let i = 0; i < SKILL_ORDER.length; i++) {
const skillId = SKILL_ORDER[i]
const skillState = state.skillStates.get(skillId)
if (skillState?.status === 'stopped') {
// Mark the category as stopped
const [category] = skillId.split('.')
if (
category === 'basic' ||
category === 'fiveComplements' ||
category === 'fiveComplementsSub'
) {
lastStoppedCategory = category
}
}
if (skillState?.status === 'pending') {
// Check if this skill's prerequisites are met
const [category] = skillId.split('.')
// If basic skills are stopped, don't test higher skills
if (lastStoppedCategory === 'basic') {
continue
}
// If five complements are stopped, don't test ten complements in same operation type
if (lastStoppedCategory === 'fiveComplements' && category === 'tenComplements') {
continue
}
if (lastStoppedCategory === 'fiveComplementsSub' && category === 'tenComplementsSub') {
continue
}
return i
}
}
return -1
}
/**
* Get placement test results summary
*/
export interface PlacementResults {
masteredSkills: string[]
practicingSkills: string[]
totalProblems: number
totalCorrect: number
overallAccuracy: number
/** Suggested level based on mastered skills */
suggestedLevel: string
}
export function getPlacementResults(state: PlacementTestState): PlacementResults {
const masteredSkills: string[] = []
const practicingSkills: string[] = []
for (const [skillId, skillState] of state.skillStates) {
if (skillState.status === 'mastered') {
masteredSkills.push(skillId)
} else if (skillState.status === 'practicing') {
practicingSkills.push(skillId)
}
}
// Determine suggested level
const hasTenComplements = masteredSkills.some((s) => s.startsWith('tenComplements'))
const hasFiveComplements = masteredSkills.some((s) => s.startsWith('fiveComplements'))
const hasBasics = masteredSkills.some((s) => s.startsWith('basic'))
let suggestedLevel = 'Beginner'
if (hasTenComplements) {
suggestedLevel = 'Level 3 - Ten Complements'
} else if (hasFiveComplements) {
suggestedLevel = 'Level 2 - Five Complements'
} else if (hasBasics) {
suggestedLevel = 'Level 1 - Basic Operations'
}
return {
masteredSkills,
practicingSkills,
totalProblems: state.problemsAnswered,
totalCorrect: state.totalCorrect,
overallAccuracy: state.problemsAnswered > 0 ? state.totalCorrect / state.problemsAnswered : 0,
suggestedLevel,
}
}