Add visual debug mode and documentation for flowchart system

- Add DebugMermaidDiagram component to visualize flowchart with current
  node highlighted in debug mode
- Add DebugStepTimeline for undo/redo navigation through flowchart steps
- Add rawMermaid field to ExecutableFlowchart schema for debug rendering
- Add comprehensive README.md documenting the flowchart walker system
- Add JSDoc comments to parser.ts, loader.ts, definitions/index.ts
- Add flowchart section to .claude/CLAUDE.md
- Add PageWithNav hamburger menu to flowchart page
- Fix MathDisplay to show implicit coefficient of 1 (1x → x)
- Install mermaid dependency for flowchart visualization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-18 09:24:28 -06:00
parent f67b39f315
commit 5ebd3008f6
17 changed files with 2143 additions and 103 deletions

View File

@ -1382,6 +1382,48 @@ If you find yourself:
3. [ ] Am I using `mutation.isPending` instead of manual loading state?
4. [ ] Am I NOT using `router.refresh()` for cache updates?
## Flowchart Walker System
When working on the interactive flowchart walker system, refer to:
- **[`src/lib/flowcharts/README.md`](../src/lib/flowcharts/README.md)** - Complete system documentation
- Architecture overview (JSON definitions + Mermaid content)
- Where to find files for each flowchart
- Node types and their behavior
- Data flow and key functions
- Adding new flowcharts
**CRITICAL: Finding Mermaid Content**
Mermaid content is **NOT always in separate `.mmd` files!** Many flowcharts embed their mermaid content directly in `definitions/index.ts`.
| Flowchart ID | JSON Definition | Mermaid Content |
|--------------|-----------------|-----------------|
| `subtraction-regrouping` | `definitions/subtraction-regrouping.flow.json` | `definitions/subtraction-regrouping-flowchart.mmd` |
| `fraction-add-sub` | `definitions/fraction-add-sub.flow.json` | **EMBEDDED** in `definitions/index.ts` as `FRACTION_MERMAID` |
| `linear-equations` | `definitions/linear-equations.flow.json` | **EMBEDDED** in `definitions/index.ts` as `LINEAR_EQUATIONS_MERMAID` |
**To find node content for a flowchart:**
1. **First check `definitions/index.ts`** - search for the node ID (e.g., `READY1`)
2. If not embedded, check the `.mmd` file referenced in the JSON's `mermaidFile` field
**Key Files:**
- `src/lib/flowcharts/definitions/index.ts` - **Registry + EMBEDDED MERMAID CONTENT**
- `src/lib/flowcharts/definitions/*.flow.json` - JSON behavior definitions
- `src/lib/flowcharts/loader.ts` - Merges JSON + Mermaid into ExecutableFlowchart
- `src/lib/flowcharts/parser.ts` - Parses Mermaid content into nodes/edges/phases
- `src/lib/flowcharts/evaluator.ts` - Expression evaluation engine
- `src/components/flowchart/FlowchartWalker.tsx` - Main UI component
**Two-File Architecture:**
Each flowchart has two parts:
1. **JSON definition** (`.flow.json`): Node types, validation logic, variables, constraints
2. **Mermaid content** (`.mmd` or embedded): Visual presentation, node text, phases
The loader merges these into an `ExecutableFlowchart` at runtime.
## Daily Practice System
When working on the curriculum-based daily practice system, refer to:

View File

@ -93,6 +93,7 @@
"lib0": "^0.2.114",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",
"mermaid": "^11.12.2",
"nanoid": "^5.1.6",
"next": "^14.2.32",
"next-auth": "5.0.0-beta.29",

View File

@ -7,8 +7,9 @@ import type { ExecutableFlowchart, ProblemValue } from '@/lib/flowcharts/schema'
import { loadFlowchart } from '@/lib/flowcharts/loader'
import { getFlowchart } from '@/lib/flowcharts/definitions'
import { FlowchartWalker, FlowchartProblemInput } from '@/components/flowchart'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
import { vstack, hstack } from '../../../../styled-system/patterns'
import { vstack } from '../../../../styled-system/patterns'
type PageState =
| { type: 'loading' }
@ -101,11 +102,8 @@ export default function FlowchartPage() {
router.push('/flowchart')
}, [router])
// Render based on state
return (
<div className={vstack({ gap: '4', padding: '4', minHeight: '100vh' })}>
{/* Header */}
<header className={hstack({ width: '100%', justifyContent: 'flex-start' })}>
// Nav slot content - Back to flowcharts link
const navSlot = (
<Link
href="/flowchart"
className={css({
@ -117,8 +115,12 @@ export default function FlowchartPage() {
>
Back to flowcharts
</Link>
</header>
)
// Render based on state
return (
<PageWithNav navSlot={navSlot}>
<div className={vstack({ gap: '4', padding: '4', minHeight: '100vh' })}>
{/* Main content */}
<main
className={css({
@ -195,5 +197,6 @@ export default function FlowchartPage() {
)}
</main>
</div>
</PageWithNav>
)
}

View File

@ -27,9 +27,9 @@ export function AnimatedMathDisplay({
}: AnimatedMathDisplayProps) {
const prevExpressionRef = useRef<string>(expression)
const [layers, setLayers] = useState<
Array<{ expression: string; opacity: number; id: number }>
>([{ expression, opacity: 1, id: 0 }])
const [layers, setLayers] = useState<Array<{ expression: string; opacity: number; id: number }>>([
{ expression, opacity: 1, id: 0 },
])
const idCounter = useRef(1)

View File

@ -0,0 +1,145 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { css } from '../../../styled-system/css'
interface DebugMermaidDiagramProps {
/** Raw mermaid content */
mermaidContent: string
/** Current node ID to highlight */
currentNodeId: string
}
/**
* DebugMermaidDiagram - Renders a mermaid flowchart with the current node highlighted.
*
* Only rendered when visual debug mode is enabled.
* Uses mermaid.js to render the flowchart SVG with custom styling for the current node.
*/
export function DebugMermaidDiagram({ mermaidContent, currentNodeId }: DebugMermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let mounted = true
async function renderDiagram() {
if (!containerRef.current) return
setIsLoading(true)
setError(null)
try {
// Dynamic import to avoid SSR issues
const mermaid = (await import('mermaid')).default
// Initialize mermaid with custom config
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis',
},
})
// Add style definition to highlight the current node
// We append this to the mermaid content
const highlightStyle = `
style ${currentNodeId} fill:#fbbf24,stroke:#d97706,stroke-width:4px,color:#000
`
// Insert the highlight style before the last closing style or at the end
const contentWithHighlight = mermaidContent + '\n' + highlightStyle
// Generate unique ID for this render
const id = `mermaid-debug-${Date.now()}`
// Render the diagram
const { svg } = await mermaid.render(id, contentWithHighlight)
if (mounted && containerRef.current) {
containerRef.current.innerHTML = svg
// Make the SVG responsive
const svgElement = containerRef.current.querySelector('svg')
if (svgElement) {
svgElement.style.maxWidth = '100%'
svgElement.style.height = 'auto'
}
}
} catch (err) {
console.error('Mermaid render error:', err)
if (mounted) {
setError(err instanceof Error ? err.message : 'Failed to render diagram')
}
} finally {
if (mounted) {
setIsLoading(false)
}
}
}
renderDiagram()
return () => {
mounted = false
}
}, [mermaidContent, currentNodeId])
if (error) {
return (
<div
className={css({
padding: '4',
backgroundColor: { base: 'red.50', _dark: 'red.900/30' },
borderRadius: 'lg',
color: { base: 'red.700', _dark: 'red.300' },
fontSize: 'sm',
})}
>
Failed to render flowchart: {error}
</div>
)
}
return (
<div
data-testid="debug-mermaid-diagram"
className={css({
padding: '4',
backgroundColor: { base: 'white', _dark: 'gray.800' },
borderRadius: 'lg',
border: '1px solid',
borderColor: { base: 'gray.200', _dark: 'gray.700' },
overflow: 'auto',
maxHeight: '400px',
})}
>
{isLoading && (
<div
className={css({
textAlign: 'center',
padding: '4',
color: { base: 'gray.500', _dark: 'gray.400' },
fontSize: 'sm',
})}
>
Loading flowchart...
</div>
)}
<div
ref={containerRef}
className={css({
display: isLoading ? 'none' : 'block',
'& svg': {
maxWidth: '100%',
height: 'auto',
},
})}
/>
</div>
)
}

View File

@ -0,0 +1,320 @@
'use client'
import { css } from '../../../styled-system/css'
import { hstack } from '../../../styled-system/patterns'
import type { FlowchartState } from '@/lib/flowcharts/schema'
interface TimelineStep {
state: FlowchartState
nodeTitle: string
}
interface DebugStepTimelineProps {
/** All states in the timeline (history + current + redo) */
steps: TimelineStep[]
/** Index of current step */
currentIndex: number
/** Navigate to specific step */
onNavigate: (index: number) => void
/** Can go back */
canGoBack: boolean
/** Can go forward (redo stack has items) */
canGoForward: boolean
/** Can skip (not at terminal) */
canSkip: boolean
onBack: () => void
onForward: () => void
/** Skip/auto-advance through current node */
onSkip: () => void
/** Whether auto-advance is paused */
autoAdvancePaused: boolean
/** Toggle auto-advance pause */
onToggleAutoAdvance: () => void
}
/**
* DebugStepTimeline - A horizontal timeline showing all steps when visual debug is enabled.
*
* Shows:
* - Past steps (green) - from stateHistory
* - Current step (blue, highlighted)
* - Future steps (gray) - from redoStack
*
* Users can click any step to jump directly to it.
*/
export function DebugStepTimeline({
steps,
currentIndex,
onNavigate,
canGoBack,
canGoForward,
canSkip,
onBack,
onForward,
onSkip,
autoAdvancePaused,
onToggleAutoAdvance,
}: DebugStepTimelineProps) {
if (steps.length === 0) return null
return (
<div
data-testid="debug-step-timeline"
data-current-index={currentIndex}
data-step-count={steps.length}
className={css({
padding: '3',
backgroundColor: { base: 'purple.50', _dark: 'purple.900/30' },
borderRadius: 'lg',
border: '2px dashed',
borderColor: { base: 'purple.300', _dark: 'purple.700' },
})}
>
{/* Header with nav buttons */}
<div
className={hstack({
gap: '3',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2',
})}
>
{/* Back button */}
<button
data-testid="debug-back-button"
onClick={onBack}
disabled={!canGoBack}
className={css({
padding: '1 2',
fontSize: 'sm',
fontWeight: 'medium',
borderRadius: 'md',
border: '1px solid',
borderColor: { base: 'purple.300', _dark: 'purple.600' },
backgroundColor: { base: 'white', _dark: 'purple.900' },
color: canGoBack
? { base: 'purple.700', _dark: 'purple.300' }
: { base: 'gray.400', _dark: 'gray.600' },
cursor: canGoBack ? 'pointer' : 'not-allowed',
opacity: canGoBack ? 1 : 0.5,
transition: 'all 0.15s',
_hover: canGoBack
? {
backgroundColor: { base: 'purple.100', _dark: 'purple.800' },
}
: {},
})}
>
Prev
</button>
{/* Title and auto-advance toggle */}
<div className={hstack({ gap: '3', alignItems: 'center' })}>
<span
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: { base: 'purple.700', _dark: 'purple.300' },
textTransform: 'uppercase',
letterSpacing: 'wide',
})}
>
Debug ({currentIndex + 1}/{steps.length})
</span>
{/* Auto-advance toggle */}
<button
data-testid="debug-auto-advance-toggle"
onClick={onToggleAutoAdvance}
title={autoAdvancePaused ? 'Auto-advance is paused' : 'Auto-advance is enabled'}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1',
padding: '1 2',
fontSize: '2xs',
fontWeight: 'medium',
borderRadius: 'md',
border: '1px solid',
cursor: 'pointer',
transition: 'all 0.15s',
borderColor: autoAdvancePaused
? { base: 'orange.400', _dark: 'orange.600' }
: { base: 'gray.300', _dark: 'gray.600' },
backgroundColor: autoAdvancePaused
? { base: 'orange.100', _dark: 'orange.900/50' }
: { base: 'gray.100', _dark: 'gray.800' },
color: autoAdvancePaused
? { base: 'orange.700', _dark: 'orange.300' }
: { base: 'gray.600', _dark: 'gray.400' },
_hover: {
backgroundColor: autoAdvancePaused
? { base: 'orange.200', _dark: 'orange.800/50' }
: { base: 'gray.200', _dark: 'gray.700' },
},
})}
>
<span>{autoAdvancePaused ? '⏸' : '▶'}</span>
<span>Auto</span>
</button>
</div>
{/* Navigation buttons */}
<div className={hstack({ gap: '2' })}>
{/* Forward button (redo) */}
<button
data-testid="debug-forward-button"
onClick={onForward}
disabled={!canGoForward}
title="Redo - go forward to previously visited step"
className={css({
padding: '1 2',
fontSize: 'sm',
fontWeight: 'medium',
borderRadius: 'md',
border: '1px solid',
borderColor: { base: 'purple.300', _dark: 'purple.600' },
backgroundColor: { base: 'white', _dark: 'purple.900' },
color: canGoForward
? { base: 'purple.700', _dark: 'purple.300' }
: { base: 'gray.400', _dark: 'gray.600' },
cursor: canGoForward ? 'pointer' : 'not-allowed',
opacity: canGoForward ? 1 : 0.5,
transition: 'all 0.15s',
_hover: canGoForward
? {
backgroundColor: { base: 'purple.100', _dark: 'purple.800' },
}
: {},
})}
>
Redo
</button>
{/* Skip button (auto-advance) */}
<button
data-testid="debug-skip-button"
onClick={onSkip}
disabled={!canSkip}
title="Skip - auto-answer and advance to next step"
className={css({
padding: '1 2',
fontSize: 'sm',
fontWeight: 'semibold',
borderRadius: 'md',
border: '1px solid',
borderColor: { base: 'green.400', _dark: 'green.600' },
backgroundColor: canSkip
? { base: 'green.500', _dark: 'green.600' }
: { base: 'gray.200', _dark: 'gray.700' },
color: canSkip ? 'white' : { base: 'gray.400', _dark: 'gray.600' },
cursor: canSkip ? 'pointer' : 'not-allowed',
opacity: canSkip ? 1 : 0.5,
transition: 'all 0.15s',
_hover: canSkip
? {
backgroundColor: { base: 'green.600', _dark: 'green.500' },
}
: {},
})}
>
Skip
</button>
</div>
</div>
{/* Step timeline */}
<div
data-testid="debug-timeline-steps"
className={css({
display: 'flex',
gap: '1',
overflowX: 'auto',
paddingY: '2',
scrollbarWidth: 'thin',
})}
>
{steps.map((step, idx) => {
const isPast = idx < currentIndex
const isCurrent = idx === currentIndex
const isFuture = idx > currentIndex
return (
<button
key={idx}
data-testid={`debug-step-${idx}`}
data-step-status={isCurrent ? 'current' : isPast ? 'past' : 'future'}
onClick={() => onNavigate(idx)}
title={`Step ${idx + 1}: ${step.nodeTitle}`}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1',
padding: '1 2',
borderRadius: 'md',
border: '2px solid',
cursor: isCurrent ? 'default' : 'pointer',
transition: 'all 0.15s',
flexShrink: 0,
maxWidth: '150px',
// Colors based on status
backgroundColor: isCurrent
? { base: 'blue.100', _dark: 'blue.800' }
: isPast
? { base: 'green.50', _dark: 'green.900/50' }
: { base: 'gray.100', _dark: 'gray.800' },
borderColor: isCurrent
? { base: 'blue.500', _dark: 'blue.400' }
: isPast
? { base: 'green.300', _dark: 'green.700' }
: { base: 'gray.300', _dark: 'gray.600' },
color: isCurrent
? { base: 'blue.800', _dark: 'blue.200' }
: isPast
? { base: 'green.700', _dark: 'green.300' }
: { base: 'gray.500', _dark: 'gray.400' },
// Hover states (only for non-current)
_hover: isCurrent
? {}
: {
backgroundColor: isPast
? { base: 'green.100', _dark: 'green.800' }
: { base: 'gray.200', _dark: 'gray.700' },
borderColor: isPast
? { base: 'green.400', _dark: 'green.600' }
: { base: 'gray.400', _dark: 'gray.500' },
transform: 'translateY(-1px)',
},
})}
>
{/* Step number / icon */}
<span
className={css({
fontSize: '2xs',
fontWeight: 'bold',
})}
>
{isPast ? '✓' : isCurrent ? '📍' : '○'}
</span>
{/* Step title (truncated) */}
<span
className={css({
fontSize: 'xs',
fontWeight: isCurrent ? 'semibold' : 'medium',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{step.nodeTitle.length > 12 ? `${step.nodeTitle.slice(0, 10)}` : step.nodeTitle}
</span>
</button>
)
})}
</div>
</div>
)
}

View File

@ -8,6 +8,7 @@ import type {
DecisionNode,
CheckpointNode,
} from '@/lib/flowcharts/schema'
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
import {
initializeState,
getNextNode,
@ -28,6 +29,8 @@ import { FlowchartDecision } from './FlowchartDecision'
import { FlowchartCheckpoint } from './FlowchartCheckpoint'
import { FlowchartPhaseRail } from './FlowchartPhaseRail'
import { MathDisplay } from './MathDisplay'
import { DebugStepTimeline } from './DebugStepTimeline'
import { DebugMermaidDiagram } from './DebugMermaidDiagram'
// =============================================================================
// Types
@ -86,13 +89,20 @@ export function FlowchartWalker({
const [wrongDecision, setWrongDecision] = useState<WrongDecisionState | null>(null)
// History stack for back navigation (stores full state snapshots)
const [stateHistory, setStateHistory] = useState<FlowchartState[]>([])
// Redo stack for forward navigation (when user goes back)
const [redoStack, setRedoStack] = useState<FlowchartState[]>([])
// Track checked checklist items for the current node
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set())
// Debug mode: pause auto-advance to inspect nodes
const [autoAdvancePaused, setAutoAdvancePaused] = useState(false)
// Track browser history depth for this walker session
const historyDepthRef = useRef(0)
// Flag to prevent double-handling when we programmatically go back
const isNavigatingBackRef = useRef(false)
// Visual debug mode
const { isVisualDebugEnabled } = useVisualDebugSafe()
// Current node
const currentNode = useMemo(
() => flowchart.nodes[state.currentNode],
@ -106,6 +116,39 @@ export function FlowchartWalker({
currentChecklist &&
currentChecklist.length > 0
// Build timeline steps for visual debug mode
const timelineSteps = useMemo(() => {
if (!isVisualDebugEnabled) return []
const steps: Array<{ state: FlowchartState; nodeTitle: string }> = []
// Add states from history (past steps)
for (const s of stateHistory) {
const node = flowchart.nodes[s.currentNode]
steps.push({
state: s,
nodeTitle: node?.content?.title || s.currentNode,
})
}
// Add current state
steps.push({
state,
nodeTitle: currentNode?.content?.title || state.currentNode,
})
// Add states from redo stack (future steps) - reversed so oldest redo is first
for (const s of redoStack.slice().reverse()) {
const node = flowchart.nodes[s.currentNode]
steps.push({
state: s,
nodeTitle: node?.content?.title || s.currentNode,
})
}
return steps
}, [isVisualDebugEnabled, stateHistory, state, redoStack, flowchart.nodes, currentNode])
// Reset checked items when node changes
useEffect(() => {
setCheckedItems(new Set())
@ -119,6 +162,12 @@ export function FlowchartWalker({
historyDepthRef.current--
isNavigatingBackRef.current = true
// Push current state to redo stack before going back
setState((currentState) => {
setRedoStack((prev) => [...prev, currentState])
return currentState
})
const previousState = stateHistory[stateHistory.length - 1]
setStateHistory((prev) => prev.slice(0, -1))
setState(previousState)
@ -171,6 +220,9 @@ export function FlowchartWalker({
// Save current state to history before advancing
setStateHistory((prev) => [...prev, state])
// Clear redo stack - user has made a new choice, branching invalidates redo
setRedoStack([])
// Push browser history entry so back button works
historyDepthRef.current++
window.history.pushState({ flowchartStep: historyDepthRef.current }, '')
@ -194,13 +246,16 @@ export function FlowchartWalker({
// Check if new node is terminal
if (isTerminal(flowchart, nextNodeId)) {
// If auto-advance is paused, don't auto-complete - let user inspect the terminal node
if (!autoAdvancePaused) {
setTimeout(() => {
setPhase({ type: 'complete' })
onComplete?.(newState)
}, 500)
}
}
},
[flowchart, state, currentNode, onComplete]
[flowchart, state, currentNode, onComplete, autoAdvancePaused]
)
// Go back to the previous step (uses browser history so back button stays in sync)
@ -215,6 +270,140 @@ export function FlowchartWalker({
window.history.back()
}, [stateHistory, onChangeProblem])
// Go forward to the next step (from redo stack) - for visual debug mode
const goForward = useCallback(() => {
if (redoStack.length === 0) return
// Push current state to history
setStateHistory((prev) => [...prev, state])
// Pop next state from redo stack
const nextState = redoStack[redoStack.length - 1]
setRedoStack((prev) => prev.slice(0, -1))
setState(nextState)
setPhase({ type: 'showingNode' })
setWrongAttempts(0)
setWrongDecision(null)
setCheckedItems(new Set())
historyDepthRef.current++
window.history.pushState({ flowchartStep: historyDepthRef.current }, '')
}, [redoStack, state])
// Navigate to a specific step in the full timeline (for visual debug mode)
// The timeline is: stateHistory + [current] + redoStack (reversed)
const navigateToHistoryStep = useCallback(
(targetIndex: number) => {
// Build full timeline: stateHistory + [current] + redoStack (reversed)
const fullTimeline = [...stateHistory, state, ...redoStack.slice().reverse()]
const currentIndex = stateHistory.length
if (targetIndex === currentIndex) return // Already there
if (targetIndex < 0 || targetIndex >= fullTimeline.length) return
const targetState = fullTimeline[targetIndex]
if (!targetState) return
// Calculate new stateHistory (everything before target)
const newHistory = fullTimeline.slice(0, targetIndex)
// Calculate new redoStack (everything after target, reversed back)
const newRedo = fullTimeline.slice(targetIndex + 1).reverse()
setStateHistory(newHistory)
setRedoStack(newRedo)
setState(targetState)
setPhase({ type: 'showingNode' })
setWrongAttempts(0)
setWrongDecision(null)
setCheckedItems(new Set())
// Update browser history depth to match
historyDepthRef.current = targetIndex
window.history.replaceState({ flowchartStep: historyDepthRef.current }, '')
},
[stateHistory, state, redoStack]
)
// Debug skip - auto-advance through the current node (for visual debug mode)
// For decision nodes: picks the first option
// For checkpoint nodes: computes and submits the correct answer
// For instruction nodes: just advances
const debugSkipStep = useCallback(() => {
if (!currentNode) return
const def = currentNode.definition
switch (def.type) {
case 'instruction':
case 'milestone':
// Just advance
advanceToNext()
break
case 'decision': {
// Pick the first option
const decisionDef = def as DecisionNode
if (decisionDef.options.length > 0) {
const firstOption = decisionDef.options[0]
advanceToNext(firstOption.value, firstOption.value, true)
}
break
}
case 'checkpoint': {
// Compute the correct answer and submit it
const checkpointDef = def as CheckpointNode
try {
const context = createContextFromState(state)
if (checkpointDef.inputType === 'two-numbers' && Array.isArray(checkpointDef.expected)) {
// Two-number checkpoint
const twoNumberAnswer: [number, number] = [
evaluate(checkpointDef.expected[0], context) as number,
evaluate(checkpointDef.expected[1], context) as number,
]
const newState = applyStateUpdate(
state,
state.currentNode,
flowchart,
twoNumberAnswer as unknown as ProblemValue
)
setState(newState)
advanceToNext(undefined, twoNumberAnswer as unknown as ProblemValue, true)
} else if (typeof checkpointDef.expected === 'string') {
// Single value checkpoint - expected is a string expression
const correctAnswer = evaluate(checkpointDef.expected, context) as number
const newState = applyStateUpdate(state, state.currentNode, flowchart, correctAnswer)
setState(newState)
advanceToNext(undefined, correctAnswer, true)
} else {
// Shouldn't reach here, but just advance if we do
advanceToNext()
}
} catch {
// If we can't compute the answer, just try to advance
advanceToNext()
}
break
}
case 'terminal':
// Complete
setPhase({ type: 'complete' })
onComplete?.(state)
break
default:
advanceToNext()
}
}, [currentNode, state, flowchart, advanceToNext, onComplete])
// Check if we can skip (not at terminal)
const canDebugSkip = useMemo(() => {
if (!currentNode) return false
return !isTerminal(flowchart, state.currentNode)
}, [currentNode, flowchart, state.currentNode])
// Navigate to a specific step in the working problem history
// Clicking on ledger entry i takes you to the state right after that entry was created
const navigateToStep = useCallback(
@ -322,10 +511,12 @@ export function FlowchartWalker({
expected: result?.expected ?? value,
userAnswer: value,
})
// Auto-advance after short delay
// Auto-advance after short delay (unless auto-advance is paused)
if (!autoAdvancePaused) {
setTimeout(() => {
advanceToNext(undefined, value as ProblemValue, true)
}, 1000)
}
} else {
// Wrong answer
setWrongAttempts((prev) => prev + 1)
@ -338,7 +529,7 @@ export function FlowchartWalker({
})
}
},
[flowchart, state, advanceToNext]
[flowchart, state, advanceToNext, autoAdvancePaused]
)
const handleChecklistToggle = useCallback(
@ -351,9 +542,9 @@ export function FlowchartWalker({
next.add(index)
}
// Check if all items are now checked - if so, auto-advance
// Check if all items are now checked - if so, auto-advance (unless auto-advance is paused)
const totalItems = currentChecklist?.length ?? 0
if (next.size === totalItems && totalItems > 0) {
if (next.size === totalItems && totalItems > 0 && !autoAdvancePaused) {
// Small delay so the user sees the final checkbox check
setTimeout(() => {
advanceToNext()
@ -363,7 +554,7 @@ export function FlowchartWalker({
return next
})
},
[currentChecklist, advanceToNext]
[currentChecklist, advanceToNext, autoAdvancePaused]
)
// =============================================================================
@ -557,8 +748,10 @@ export function FlowchartWalker({
}
case 'milestone':
// Auto-advance milestones
// Auto-advance milestones (unless auto-advance is paused)
if (!autoAdvancePaused) {
setTimeout(() => advanceToNext(), 500)
}
return (
<div
data-testid="milestone-display"
@ -723,6 +916,31 @@ export function FlowchartWalker({
)}
</nav>
{/* Debug step timeline - only visible when visual debug mode is enabled */}
{isVisualDebugEnabled && timelineSteps.length > 0 && (
<DebugStepTimeline
steps={timelineSteps}
currentIndex={stateHistory.length}
onNavigate={navigateToHistoryStep}
canGoBack={stateHistory.length > 0}
canGoForward={redoStack.length > 0}
canSkip={canDebugSkip}
onBack={goBack}
onForward={goForward}
onSkip={debugSkipStep}
autoAdvancePaused={autoAdvancePaused}
onToggleAutoAdvance={() => setAutoAdvancePaused((prev) => !prev)}
/>
)}
{/* Debug mermaid diagram - shows flowchart with current node highlighted */}
{isVisualDebugEnabled && flowchart.rawMermaid && (
<DebugMermaidDiagram
mermaidContent={flowchart.rawMermaid}
currentNodeId={state.currentNode}
/>
)}
{/* Phase rail with flowchart navigation */}
<FlowchartPhaseRail flowchart={flowchart} state={state} />
@ -883,7 +1101,8 @@ export function FlowchartWalker({
},
})}
>
{/* Node content */}
{/* Node content - skip for milestone nodes (they show emoji in interaction area) */}
{currentNode?.definition.type !== 'milestone' && (
<div
data-testid="node-content-container"
data-node-type={currentNode?.definition.type}
@ -904,6 +1123,7 @@ export function FlowchartWalker({
/>
)}
</div>
)}
{/* Interaction area */}
<div

View File

@ -111,9 +111,10 @@ function parseExpression(expr: string): Token[] {
const termMatch = part.match(/^(-?\d*)([a-zA-Z])$/)
if (termMatch) {
const [, coef, varName] = termMatch
if (coef === '' || coef === '-') {
// Just "x" or "-x"
tokens.push({ type: 'variable', name: coef === '-' ? `-${varName}` : varName })
if (coef === '' || coef === '-' || coef === '1' || coef === '-1') {
// Coefficient of 1 is implicit: "1x" → "x", "-1x" → "-x"
const isNegative = coef === '-' || coef === '-1'
tokens.push({ type: 'variable', name: isNegative ? `-${varName}` : varName })
} else {
tokens.push({ type: 'term', coefficient: coef, variable: varName })
}

View File

@ -0,0 +1,231 @@
# Flowchart Walker System
This directory contains the **interactive flowchart walker** system, which guides users through multi-step math procedures using visual flowcharts.
## Quick Reference: Finding Flowchart Files
**CRITICAL: Mermaid content is NOT always in separate `.mmd` files!**
| Flowchart ID | JSON Definition | Mermaid Content Location |
|--------------|-----------------|--------------------------|
| `subtraction-regrouping` | `definitions/subtraction-regrouping.flow.json` | `definitions/subtraction-regrouping-flowchart.mmd` |
| `fraction-add-sub` | `definitions/fraction-add-sub.flow.json` | **EMBEDDED** in `definitions/index.ts` as `FRACTION_MERMAID` |
| `linear-equations` | `definitions/linear-equations.flow.json` | **EMBEDDED** in `definitions/index.ts` as `LINEAR_EQUATIONS_MERMAID` |
**To find mermaid content for a flowchart:**
1. First check `definitions/index.ts` - look for `const <NAME>_MERMAID = ...`
2. If not embedded, check the `mermaidFile` field in the `.flow.json` file
3. The actual `.mmd` file will be in `definitions/` directory
## Architecture Overview
Each flowchart consists of **two parts**:
### 1. JSON Definition (`.flow.json`)
Defines **behavior and interactivity**:
- Node types (instruction, decision, checkpoint, milestone, terminal)
- Problem input schema (what values users can enter)
- Variables and computed values
- Validation rules and expected answers
- Working problem transformations
### 2. Mermaid Content (`.mmd` or embedded in `index.ts`)
Defines **visual presentation**:
- Node content (title, body text, examples, warnings, checklists)
- Visual layout and styling
- Phase/subgraph organization
- Edge labels and styling
### Why Two Files?
- **Separation of concerns**: Content authors can edit mermaid without touching logic
- **Mermaid compatibility**: Flowcharts render in standard Mermaid viewers
- **Reusability**: Same definition structure across different topics
## Directory Structure
```
lib/flowcharts/
├── README.md # This file
├── schema.ts # TypeScript types for all structures
├── parser.ts # Mermaid file parsing (extracts nodes, edges, phases)
├── loader.ts # Combines JSON + Mermaid into ExecutableFlowchart
├── evaluator.ts # Expression evaluation engine
├── constraint-parser.ts # Parses generation constraints
├── example-generator-client.ts # Client-side problem generation
├── example-generator.worker.ts # Web worker for generation
├── index.ts # Public exports
├── benchmark.ts # Performance benchmarks
├── __tests__/ # Tests
└── definitions/ # Flowchart definitions
├── index.ts # Registry + EMBEDDED MERMAID CONTENT
├── *.flow.json # JSON behavior definitions
└── *.mmd # Standalone mermaid files (if not embedded)
```
## Key Concepts
### Node Types
| Type | Purpose | User Interaction |
|------|---------|------------------|
| `instruction` | Show content | Tap to continue |
| `decision` | Ask yes/no or multiple choice | Tap choice button |
| `checkpoint` | Validate user answer | Enter value, app validates |
| `milestone` | Success marker | Auto-advances (shows emoji briefly) |
| `terminal` | End state | Shows completion screen |
### ExecutableFlowchart
The final merged structure used at runtime:
```typescript
interface ExecutableFlowchart {
definition: FlowchartDefinition // From .flow.json
mermaid: ParsedMermaid // Parsed from mermaid content
nodes: Record<string, ExecutableNode> // Merged nodes
}
interface ExecutableNode {
id: string
definition: FlowchartNode // Behavior from JSON
content: ParsedNodeContent // Display content from mermaid
}
```
### Node Content Parsing
Mermaid nodes use special formatting parsed by `parser.ts`:
```mermaid
NODE["<b>Title Here</b><br/>───────<br/>Body text line 1<br/>Body text line 2<br/>📝 Example text<br/>⚠️ Warning text"]
```
Parsed into:
```typescript
{
title: "Title Here",
body: ["Body text line 1", "Body text line 2"],
example: "Example text",
warning: "Warning text",
checklist: undefined,
raw: "..."
}
```
## Data Flow
```
┌─────────────────────┐ ┌─────────────────────┐
│ .flow.json │ │ Mermaid content │
│ (behavior) │ │ (.mmd or embedded) │
└─────────┬───────────┘ └─────────┬───────────┘
│ │
│ loadFlowchart() │
└───────────┬───────────────┘
┌───────────────────────┐
│ ExecutableFlowchart │
│ (merged structure) │
└───────────┬───────────┘
│ initializeState()
┌───────────────────────┐
│ FlowchartState │
│ (runtime state) │
└───────────┬───────────┘
│ FlowchartWalker component
┌───────────────────────┐
│ User Interface │
└───────────────────────┘
```
## Key Functions
### `definitions/index.ts`
- `getFlowchart(id)` - Get a flowchart by ID (returns definition + mermaid)
- `getFlowchartList()` - Get metadata for all flowcharts
- `FLOWCHARTS` - Registry mapping IDs to definitions
### `loader.ts`
- `loadFlowchart(definition, mermaid)` - Merge JSON and mermaid into ExecutableFlowchart
- `initializeState(flowchart, problemInput)` - Create initial runtime state
- `advanceState(state, nextNode, ...)` - Move to next node
- `validateCheckpoint(flowchart, node, state, input)` - Check user answer
- `isDecisionCorrect(flowchart, node, state, choice)` - Check decision choice
- `formatProblemDisplay(flowchart, problem)` - Format problem for display
### `parser.ts`
- `parseMermaidFile(content)` - Parse mermaid into nodes, edges, phases
- `parseNodeContent(raw)` - Parse node label into structured content
- `getNextNodes(mermaid, nodeId)` - Get successor nodes
- `findNodePhase(mermaid, nodeId)` - Find which phase a node belongs to
### `evaluator.ts`
- `evaluate(expression, context)` - Evaluate math/logic expressions
- Supports: arithmetic, comparisons, boolean logic, ternary, functions (gcd, lcm, floor, etc.)
## Adding a New Flowchart
1. **Create the JSON definition** (`definitions/my-flowchart.flow.json`):
```json
{
"id": "my-flowchart",
"title": "My Flowchart",
"mermaidFile": "my-flowchart.mmd",
"problemInput": { ... },
"variables": { ... },
"entryNode": "START",
"nodes": { ... }
}
```
2. **Create the mermaid content** - either:
- Standalone file: `definitions/my-flowchart.mmd`
- OR embed in `definitions/index.ts` as `const MY_FLOWCHART_MERMAID = \`...\``
3. **Register in `definitions/index.ts`**:
```typescript
import myDefinition from './my-flowchart.flow.json'
export const FLOWCHARTS = {
'my-flowchart': {
definition: myDefinition as FlowchartDefinition,
mermaid: MY_FLOWCHART_MERMAID, // or read from .mmd file
meta: { id: 'my-flowchart', title: '...', ... }
},
// ...
}
```
## Debugging Tips
### Finding why a node looks wrong
1. **Find the node ID** - Look at `data-current-node` attribute in browser DevTools
2. **Check the mermaid content** - Look in `definitions/index.ts` for embedded mermaid
3. **Check the JSON definition** - Look in `.flow.json` for node type and behavior
4. **Check the parsed content** - Log `currentNode.content` in FlowchartWalker
### Common issues
| Issue | Likely Cause |
|-------|--------------|
| Node shows only emoji | Mermaid node has no `<b>` title, just emoji like `(("👍"))` |
| Content shows twice | Node content rendered by both container and interaction area |
| Wrong answer marked correct | Check `expected` expression in checkpoint definition |
| Node not advancing | Check `next` field or `edges` in JSON definition |
## Related Components
- `components/flowchart/FlowchartWalker.tsx` - Main walker UI component
- `components/flowchart/FlowchartNodeContent.tsx` - Renders parsed node content
- `components/flowchart/FlowchartDecision.tsx` - Decision button UI
- `components/flowchart/FlowchartCheckpoint.tsx` - Checkpoint input UI
- `components/flowchart/FlowchartPhaseRail.tsx` - Phase progress indicator
- `components/flowchart/DebugStepTimeline.tsx` - Visual debug timeline
- `app/flowchart/page.tsx` - Flowchart picker page
- `app/flowchart/[flowchartId]/page.tsx` - Flowchart walker page

View File

@ -1,7 +1,36 @@
/**
* Flowchart Definitions Index
* Flowchart Definitions Registry
*
* Exports all available flowchart definitions with their Mermaid content.
* **THIS FILE CONTAINS EMBEDDED MERMAID CONTENT!**
*
* Each flowchart has two parts:
* 1. **JSON definition** (`.flow.json`): Behavior, validation, variables
* 2. **Mermaid content**: Visual presentation, node text, phases
*
* ## Where is the Mermaid content?
*
* | Flowchart | Mermaid Location |
* |-----------|------------------|
* | subtraction-regrouping | Embedded below as `SUBTRACTION_MERMAID` |
* | fraction-add-sub | Embedded below as `FRACTION_MERMAID` |
* | linear-equations | Embedded below as `LINEAR_EQUATIONS_MERMAID` |
*
* **To find node content**: Search this file for the node ID (e.g., `READY1`) in
* the appropriate `*_MERMAID` constant.
*
* ## Why Embed Mermaid?
*
* Next.js doesn't support `?raw` imports for loading text files.
* Embedding the mermaid content as template strings is the simplest solution.
*
* ## Adding a New Flowchart
*
* 1. Create `my-flowchart.flow.json` in this directory
* 2. Add `const MY_FLOWCHART_MERMAID = \`...\`` below
* 3. Import the JSON and add to `FLOWCHARTS` registry
*
* @see {@link ../README.md} for complete system documentation
* @module flowcharts/definitions
*/
import type { FlowchartDefinition } from '../schema'
@ -9,7 +38,17 @@ import subtractionDefinition from './subtraction-regrouping.flow.json'
import fractionDefinition from './fraction-add-sub.flow.json'
import linearEquationsDefinition from './linear-equations.flow.json'
// Mermaid content embedded as strings (since Next.js doesn't support ?raw imports)
// =============================================================================
// EMBEDDED MERMAID CONTENT
// =============================================================================
// These constants contain the visual content for each flowchart.
// Search for node IDs (e.g., "READY1", "STEP0") to find their content.
/**
* Mermaid content for subtraction-regrouping flowchart.
* Nodes: START, COMPARE, HAPPY, SAD, CHECK1, CHECK1B, NEEDIT, SKIP, TENS,
* TAKEONE, BREAK, ADDTEN, CHECK2, DOONES, DOTENS, DONE
*/
const SUBTRACTION_MERMAID = `%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '18px', 'primaryColor': '#e3f2fd', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#90caf9', 'lineColor': '#444444'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 20}}}%%
flowchart TB
subgraph PHASE1["<b>1. 👀 LOOK</b>"]
@ -68,6 +107,16 @@ flowchart TB
style DONE fill:#66bb6a,stroke:#2e7d32,stroke-width:2px
`
/**
* Mermaid content for fraction-add-sub flowchart.
* Nodes: STEP0, STEP1, READY1, READY2, READY3, STEP2, CONV1A, CONV1B, CONV1C,
* STEP3, STEP3B, CHECK1, REMIND, ADDSUB, GOSTEP4, GOSTEP4B, GOSTEP4C,
* BORROWCHECK, BORROW, CHECK2, STEP4, SIMPLIFY_Q, SIMPLIFY_HOW,
* IMPROPER_Q, MIXED_HOW, CHECK3, DONE
*
* NOTE: Milestone nodes (READY1, READY2, READY3, GOSTEP4, etc.) only contain
* emoji like (("👍")) - they display briefly before auto-advancing.
*/
const FRACTION_MERMAID = `%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px', 'primaryColor': '#e3f2fd', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#90caf9', 'lineColor': '#444444'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 25, 'rankSpacing': 40, 'padding': 15}}}%%
flowchart TB
subgraph PHASE1["<b>1. 🔍 MAKE THE BOTTOMS MATCH</b>"]
@ -122,6 +171,12 @@ flowchart TB
style PHASE3 fill:#e8f5e9,stroke:#388e3c,stroke-width:3px
`
/**
* Mermaid content for linear-equations flowchart.
* Nodes: INTRO, BALANCE, FIND_OP, STUCK_ADD, STUCK_MUL, CHECK1, GOAL,
* HOWSTUCK, ZERO, ONE, MAKEZ, MAKEONE, EX_ADD, EX_MUL, REMIND,
* CHECK2, PLUG, MATCH, DONE, RETRY
*/
const LINEAR_EQUATIONS_MERMAID = `%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px', 'primaryColor': '#e3f2fd', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#90caf9', 'lineColor': '#444444'}, 'flowchart': {'curve': 'basis', 'nodeSpacing': 25, 'rankSpacing': 40, 'padding': 15}}}%%
flowchart TB
subgraph PHASE1["<b>1. 🔍 UNDERSTAND THE EQUATION</b>"]

View File

@ -1,7 +1,41 @@
/**
* Flowchart Loader
*
* Loads and merges .mmd and .flow.json files into an executable flowchart.
* Combines JSON definitions (`.flow.json`) with Mermaid content (`.mmd` or embedded)
* to create executable flowcharts, and manages runtime state as users walk through them.
*
* ## Key Functions
*
* - {@link loadFlowchart} - Merge JSON definition + Mermaid into ExecutableFlowchart
* - {@link initializeState} - Create initial runtime state from problem input
* - {@link advanceState} - Move to next node in the flowchart
* - {@link validateCheckpoint} - Check if user's answer is correct
* - {@link isDecisionCorrect} - Check if user chose the correct option
* - {@link formatProblemDisplay} - Format problem values for display
*
* ## Data Flow
*
* ```
* FlowchartDefinition + Mermaid content
*
* loadFlowchart()
*
* ExecutableFlowchart
*
* initializeState(flowchart, problemInput)
*
* FlowchartState
*
* advanceState(), validateCheckpoint(), etc.
* ```
*
* ## Where to Find Mermaid Content
*
* **IMPORTANT**: Mermaid content is NOT always in separate `.mmd` files!
* Check `definitions/index.ts` first - many flowcharts embed their mermaid as constants.
*
* @see {@link ./README.md} for complete system documentation
* @module flowcharts/loader
*/
import type {
@ -32,7 +66,33 @@ import {
// =============================================================================
/**
* Load and merge a flowchart definition with its Mermaid content
* Load and merge a flowchart definition with its Mermaid content.
*
* This is the main entry point for creating an executable flowchart.
* It combines:
* - **JSON definition** (`.flow.json`): Node types, validation logic, variables
* - **Mermaid content**: Node display content, phases, visual structure
*
* ## Node Merging
*
* For each node ID:
* 1. If in JSON definition: uses that node type/behavior
* 2. If only in Mermaid: creates default `instruction` node
* 3. Content always comes from Mermaid (parsed via `parseNodeContent`)
*
* ## Common Usage
*
* ```typescript
* import { getFlowchart } from './definitions'
* import { loadFlowchart } from './loader'
*
* const data = getFlowchart('fraction-add-sub')
* const flowchart = await loadFlowchart(data.definition, data.mermaid)
* ```
*
* @param definition - The JSON definition from `.flow.json`
* @param mermaidContent - The Mermaid content (from `.mmd` file or embedded string)
* @returns Promise resolving to executable flowchart ready for FlowchartWalker
*/
export async function loadFlowchart(
definition: FlowchartDefinition,
@ -78,6 +138,7 @@ export async function loadFlowchart(
return {
definition,
mermaid,
rawMermaid: mermaidContent,
nodes,
}
}

View File

@ -1,10 +1,36 @@
/**
* Mermaid Flowchart Parser
*
* Extracts structure from .mmd files:
* - Node IDs and content
* - Edge connections
* - Subgraph (phase) definitions
* Extracts structure from Mermaid flowchart content (from .mmd files or embedded strings).
*
* ## Key Concepts
*
* - **Node content** is stored in Mermaid node labels using special formatting
* - **Phases** are Mermaid subgraphs that group related nodes
* - **Edges** connect nodes and may have labels
*
* ## Content Formatting
*
* Mermaid node labels use HTML-like formatting:
* - `<b>...</b>` - Title (extracted separately)
* - `<br/>` - Line breaks
* - `<i>...</i>` - Italic (used for examples)
* - `───────` - Dividers (ignored)
* - `📝` - Example marker
* - `⚠️` - Warning marker
* - `` / `` - Checklist items
*
* @example
* ```typescript
* import { parseMermaidFile, parseNodeContent } from './parser'
*
* const mermaid = parseMermaidFile(mermaidContent)
* const nodeContent = mermaid.nodes['START']
* const parsed = parseNodeContent(nodeContent)
* console.log(parsed.title, parsed.body)
* ```
*
* @module flowcharts/parser
*/
import type { ParsedMermaid, ParsedNodeContent, ParsedEdge } from './schema'
@ -14,14 +40,49 @@ import type { ParsedMermaid, ParsedNodeContent, ParsedEdge } from './schema'
// =============================================================================
/**
* Parse the raw content string from a Mermaid node label.
* Parse the raw content string from a Mermaid node label into structured content.
*
* Mermaid node content uses:
* - <b>...</b> for bold (title)
* - <br/> for line breaks
* - <i>...</i> for italic (examples)
* - for dividers
* - Emojis throughout
* ## Input Format
*
* Mermaid node content uses HTML-like formatting:
* - `<b>...</b>` - Bold text becomes the **title**
* - `<br/>` - Line breaks separate content
* - `<i>...</i>` - Italic text (treated as examples)
* - `───────` - Divider lines (ignored)
* - `📝` - Marks example text
* - `⚠️` - Marks warning text
* - `` / `` - Checklist items
*
* ## Output Structure
*
* ```typescript
* {
* title: "The title text", // From <b>...</b> or first line
* body: ["Line 1", "Line 2"], // Main content lines
* example: "Example text", // Lines after 📝 or <i>
* warning: "Warning text", // Lines with ⚠️
* checklist: ["☐ Item 1"], // Lines with checkboxes
* raw: "original content" // Original for fallback
* }
* ```
*
* ## Edge Cases
*
* - **Emoji-only nodes** (like milestone `(("👍"))`): Title becomes the emoji, body is empty
* - **No `<b>` tags**: First line becomes the title
* - **Multi-line titles**: `<b>Line 1<br/>Line 2</b>` becomes a single-line title
*
* @param raw - The raw content string from a Mermaid node label
* @returns Parsed and structured node content
*
* @example
* ```typescript
* const content = parseNodeContent('<b>Step 1</b><br/>Do this thing<br/>📝 Example: 3 + 4 = 7')
* // { title: "Step 1", body: ["Do this thing"], example: "Example: 3 + 4 = 7", ... }
*
* const emoji = parseNodeContent('👍')
* // { title: "👍", body: [], ... }
* ```
*/
export function parseNodeContent(raw: string): ParsedNodeContent {
// Decode HTML entities that might be in the content
@ -131,7 +192,43 @@ function stripHtml(str: string): string {
// =============================================================================
/**
* Parse a complete Mermaid flowchart file
* Parse a complete Mermaid flowchart into nodes, edges, and phases.
*
* ## What It Extracts
*
* - **Nodes**: ID raw content mapping (content is NOT parsed here, use `parseNodeContent`)
* - **Edges**: From To connections with optional labels
* - **Phases**: Subgraph groupings with title and contained node IDs
*
* ## Node Shapes Supported
*
* - `ID["content"]` - Rectangle
* - `ID{"content"}` - Diamond (decision)
* - `ID(["content"])` - Stadium (rounded rectangle)
* - `ID(("content"))` - Circle (milestones, often emoji-only)
*
* ## Edge Format
*
* - `A --> B` - Simple edge
* - `A -->|"label"| B` - Edge with label
*
* @param content - The complete Mermaid flowchart content (from .mmd file or embedded string)
* @returns Parsed structure with nodes, edges, and phases
*
* @example
* ```typescript
* const mermaid = parseMermaidFile(`
* flowchart TB
* subgraph PHASE1["Step 1"]
* START["<b>Begin</b>"] --> DECISION{"<b>Continue?</b>"}
* DECISION -->|"Yes"| DONE(("👍"))
* end
* `)
*
* mermaid.nodes['START'] // '<b>Begin</b>'
* mermaid.edges[0] // { from: 'START', to: 'DECISION' }
* mermaid.phases[0] // { id: 'PHASE1', title: 'Step 1', nodes: ['START', 'DECISION', 'DONE'] }
* ```
*/
export function parseMermaidFile(content: string): ParsedMermaid {
const nodes: Record<string, string> = {}

View File

@ -1,8 +1,53 @@
/**
* Flowchart Walker Schema Types
*
* Defines the structure of .flow.json companion files that add
* interactivity metadata to .mmd Mermaid flowcharts.
* This module defines all TypeScript types for the flowchart walker system.
* These types correspond to the structure of `.flow.json` files and the
* runtime state used during flowchart execution.
*
* ## Architecture Overview
*
* ```
* FlowchartDefinition (from .flow.json)
* problemInput: ProblemInputSchema # User input form definition
* variables: Record<string, VariableDefinition> # Computed values
* nodes: Record<string, FlowchartNode> # Node behavior definitions
* workingProblem?: WorkingProblemConfig # Evolving problem display
* constraints?: GenerationConstraints # Problem generation rules
*
* ParsedMermaid (from .mmd or embedded)
* nodes: Record<string, string> # Raw node content
* edges: ParsedEdge[] # Connections between nodes
* phases: Phase[] # Subgraph groupings
*
* ExecutableFlowchart = FlowchartDefinition + ParsedMermaid merged
* nodes: Record<string, ExecutableNode> # Ready for display
*
* FlowchartState (runtime)
* problem: user input values
* computed: calculated variables
* currentNode: where we are
* history: actions taken
* ```
*
* ## Node Types
*
* | Type | Purpose | User Action |
* |------|---------|-------------|
* | `instruction` | Show content | Tap to continue |
* | `decision` | Yes/No or multiple choice | Tap option |
* | `checkpoint` | Validate user answer | Enter value |
* | `milestone` | Success marker | Auto-advances |
* | `terminal` | End state | Shows completion |
*
* ## File Locations
*
* - JSON definitions: `lib/flowcharts/definitions/*.flow.json`
* - Mermaid content: `lib/flowcharts/definitions/index.ts` (embedded) or `*.mmd`
* - This file: Type definitions only, no runtime logic
*
* @see {@link ../README.md} for complete system documentation
* @module flowcharts/schema
*/
// =============================================================================
@ -434,5 +479,7 @@ export interface ExecutableNode {
export interface ExecutableFlowchart {
definition: FlowchartDefinition
mermaid: ParsedMermaid
/** Raw mermaid content string (for debug rendering) */
rawMermaid: string
nodes: Record<string, ExecutableNode>
}

View File

@ -84,8 +84,7 @@ describe('A/B Performance Benchmark', () => {
console.log(` Run ${run + 1}: ${elapsed.toFixed(1)}ms`)
}
const nonMemoizedMean =
nonMemoizedTimes.reduce((a, b) => a + b, 0) / nonMemoizedTimes.length
const nonMemoizedMean = nonMemoizedTimes.reduce((a, b) => a + b, 0) / nonMemoizedTimes.length
// ============= MEMOIZED =============
console.log(`\n🚀 Testing MEMOIZED...`)

View File

@ -166,7 +166,9 @@ describe('Session Generation Benchmark', () => {
console.log(` Per problem: ${(finalTime / 60).toFixed(1)}ms`)
console.log(` Cache entries: ${finalStats.size}`)
console.log(` Cache hits: ${finalStats.hits.toLocaleString()}`)
console.log(` Cache hit rate: ${((finalStats.hits / (finalStats.hits + finalStats.misses)) * 100).toFixed(1)}%`)
console.log(
` Cache hit rate: ${((finalStats.hits / (finalStats.hits + finalStats.misses)) * 100).toFixed(1)}%`
)
// Phase 2 recommendation
console.log(`\n🤔 PHASE 2 RECOMMENDATION:`)

View File

@ -72,9 +72,10 @@ describe('analyzeStepSkills memoization', () => {
{ currentValue: 100, term: -1, description: '100-1=99 borrow from hundreds' },
]
it.each(testCases)(
'$description produces identical results memoized vs non-memoized',
({ currentValue, term }) => {
it.each(testCases)('$description produces identical results memoized vs non-memoized', ({
currentValue,
term,
}) => {
const newValue = currentValue + term
// First call (cache miss) - should compute fresh
@ -85,8 +86,7 @@ describe('analyzeStepSkills memoization', () => {
// Results must be identical
expect(memoizedResult).toEqual(directResult)
}
)
})
it('repeated calls return identical arrays', () => {
// Call multiple times with same inputs
@ -273,7 +273,9 @@ describe('performance benchmark', () => {
expect(stats.hits).toBeGreaterThanOrEqual(operations.length * 2)
// Log for manual verification
console.log(`Memoized: ${memoizedTime.toFixed(2)}ms for ${repeatedOperations.length} operations`)
console.log(
`Memoized: ${memoizedTime.toFixed(2)}ms for ${repeatedOperations.length} operations`
)
console.log(`Cache stats: ${stats.size} entries, ${stats.hits} hits, ${stats.misses} misses`)
console.log(`Hit rate: ${((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(1)}%`)
})

File diff suppressed because it is too large Load Diff