Files
soroban-abacus-flashcards/apps/web/src/components/practice/BrowseModeView.tsx
Thomas Hallock c0764ccd85 feat(practice): add inline practice panel for browse mode debugging
Add PracticePreview component that allows practicing any problem while
in browse mode without affecting session state. The practice panel
displays inline below the problem card with a clear header indicating
it doesn't affect the session, preventing UX confusion.

- Add PracticePreview component with keyboard and numpad input support
- Add inline mode to PracticePreview for embedded display
- Update BrowseModeView to show practice panel below problem card
- Toggle button switches between "Practice This Problem" / "Close Practice Panel"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:35:34 -06:00

294 lines
8.6 KiB
TypeScript

/**
* Browse Mode View Component
*
* Allows browsing through all problems in a session during practice.
* Shows problems using DetailedProblemCard.
* Navigation is handled via SessionProgressIndicator in the nav bar.
* Does not affect actual session progress - just for viewing.
*/
'use client'
import { useMemo, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { ProblemSlot, SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { calculateAutoPauseInfo } from './autoPauseCalculator'
import { DetailedProblemCard } from './DetailedProblemCard'
import { PracticePreview } from './PracticePreview'
/**
* Flattened problem item with all context needed for display
*/
export interface LinearProblemItem {
partNumber: number
slotIndex: number
slot: ProblemSlot
part: SessionPart
linearIndex: number
}
/**
* Build a flattened list of all problems for navigation
*/
export function buildLinearProblemList(parts: SessionPart[]): LinearProblemItem[] {
const items: LinearProblemItem[] = []
let linearIndex = 0
for (const part of parts) {
for (let slotIndex = 0; slotIndex < part.slots.length; slotIndex++) {
items.push({
partNumber: part.partNumber,
slotIndex,
slot: part.slots[slotIndex],
part,
linearIndex,
})
linearIndex++
}
}
return items
}
/**
* Convert current part/slot indices to linear index
*/
export function getLinearIndex(
parts: SessionPart[],
currentPartIndex: number,
currentSlotIndex: number
): number {
let index = 0
for (let i = 0; i < currentPartIndex; i++) {
index += parts[i].slots.length
}
return index + currentSlotIndex
}
export interface BrowseModeViewProps {
/** The session plan with all problems */
plan: SessionPlan
/** Current browse index (linear) */
browseIndex: number
/** The actual current practice problem index (to highlight) */
currentPracticeIndex: number
}
/**
* Get result for a specific problem if it exists
*/
function getResultForProblem(
results: SlotResult[],
partNumber: number,
slotIndex: number
): SlotResult | undefined {
return results.find((r) => r.partNumber === partNumber && r.slotIndex === slotIndex)
}
export function BrowseModeView({ plan, browseIndex, currentPracticeIndex }: BrowseModeViewProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Practice preview mode - when true, show interactive practice interface
const [isPracticing, setIsPracticing] = useState(false)
// Build linear problem list
const linearProblems = useMemo(() => buildLinearProblemList(plan.parts), [plan.parts])
const currentItem = linearProblems[browseIndex]
// Get result for current browse item
const result = useMemo(() => {
if (!currentItem) return undefined
return getResultForProblem(plan.results, currentItem.partNumber, currentItem.slotIndex)
}, [plan.results, currentItem])
// Calculate auto-pause stats at this position
const autoPauseStats = useMemo(() => {
if (!currentItem) return undefined
// Find the position in results where this problem would be
const resultsUpToHere = plan.results.filter((r) => {
const rLinear = linearProblems.findIndex(
(p) => p.partNumber === r.partNumber && p.slotIndex === r.slotIndex
)
return rLinear < browseIndex
})
return calculateAutoPauseInfo(resultsUpToHere).stats
}, [plan.results, linearProblems, browseIndex, currentItem])
// Is this the current practice problem?
const isCurrentPractice = browseIndex === currentPracticeIndex
const isCompleted = browseIndex < currentPracticeIndex
const isUpcoming = browseIndex > currentPracticeIndex
if (!currentItem) {
return (
<div
className={css({
padding: '2rem',
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
No problems to display
</div>
)
}
return (
<div
data-component="browse-mode-view"
data-browse-index={browseIndex}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
padding: '1rem',
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Current Practice Indicator */}
{isCurrentPractice && (
<div
className={css({
padding: '0.5rem 1rem',
backgroundColor: isDark ? 'yellow.900' : 'yellow.50',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'yellow.700' : 'yellow.200',
textAlign: 'center',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'yellow.200' : 'yellow.700',
})}
>
This is your current practice problem
</div>
)}
{/* Problem Display */}
<DetailedProblemCard
slot={currentItem.slot}
part={currentItem.part}
result={result}
autoPauseStats={autoPauseStats}
isDark={isDark}
problemNumber={browseIndex + 1}
/>
{/* Action Button - Toggle practice mode */}
<div
data-element="browse-action"
className={css({
display: 'flex',
justifyContent: 'center',
gap: '0.75rem',
padding: '0.5rem 0',
})}
>
<button
type="button"
data-action={isPracticing ? 'close-practice' : 'practice-this-problem'}
onClick={() => setIsPracticing((prev) => !prev)}
className={css({
padding: '0.75rem 1.5rem',
fontSize: '1rem',
fontWeight: 'bold',
borderRadius: '8px',
border: isPracticing ? '2px solid' : 'none',
borderColor: isPracticing ? (isDark ? 'gray.500' : 'gray.400') : undefined,
cursor: 'pointer',
backgroundColor: isPracticing ? 'transparent' : isDark ? 'green.600' : 'green.500',
color: isPracticing ? (isDark ? 'gray.300' : 'gray.600') : 'white',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isPracticing
? isDark
? 'gray.700'
: 'gray.100'
: isDark
? 'green.500'
: 'green.600',
transform: 'scale(1.02)',
},
_active: {
transform: 'scale(0.98)',
},
})}
>
{isPracticing ? 'Close Practice Panel' : 'Practice This Problem'}
</button>
</div>
{/* Inline Practice Preview - shown when practicing */}
{isPracticing && (
<div
data-element="practice-panel"
className={css({
padding: '1rem',
backgroundColor: isDark ? 'blue.950' : 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'blue.800' : 'blue.200',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '1rem',
paddingBottom: '0.75rem',
borderBottom: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.200',
})}
>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'blue.200' : 'blue.700',
})}
>
Practice Panel
</span>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'blue.400' : 'blue.500',
})}
>
(does not affect session)
</span>
</div>
<PracticePreview
slot={currentItem.slot}
part={currentItem.part}
problemNumber={browseIndex + 1}
onBack={() => setIsPracticing(false)}
inline
/>
</div>
)}
{/* Status indicator */}
{(isCompleted || isUpcoming || isCurrentPractice) && (
<div
data-element="status-indicator"
className={css({
textAlign: 'center',
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
})}
>
{isCurrentPractice && '(Current problem in session)'}
{isCompleted && '(Already completed in session)'}
{isUpcoming && '(Not yet reached in session)'}
</div>
)}
</div>
)
}