refactor(practice): improve relationship display and dashboard components

- DashboardClient: refactor session observation and error boundary handling
- PracticeSubNav: streamline relationship indicator integration
- RelationshipCard: improve layout and styling
- RelationshipIndicator: simplify component structure
- useStudentRelationship: cleanup hook implementation
- Export relationship components from practice index

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-28 11:20:01 -06:00
parent 6def610877
commit c8f2984d7b
6 changed files with 160 additions and 162 deletions

View File

@ -2573,12 +2573,15 @@ export function DashboardClient({
const [isObserving, setIsObserving] = useState(false)
// Handle session observation from PracticeSubNav action menu
const handleObserveSession = useCallback((sessionId: string) => {
// We're already on this student's page, just open the observer modal
if (activeSession?.id === sessionId) {
setIsObserving(true)
}
}, [activeSession?.id])
const handleObserveSession = useCallback(
(sessionId: string) => {
// We're already on this student's page, just open the observer modal
if (activeSession?.id === sessionId) {
setIsObserving(true)
}
},
[activeSession?.id]
)
const handleTabChange = useCallback(
(tab: TabId) => {
@ -2754,114 +2757,115 @@ export function DashboardClient({
<PracticeErrorBoundary studentName={player.name}>
<main
data-component="practice-dashboard-page"
style={{
opacity: contentOpacity,
transition: contentTransition,
}}
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
padding: { base: '0.75rem', sm: '1rem', md: '1.5rem' },
})}
>
<div className={css({ maxWidth: '900px', margin: '0 auto' })}>
{/* Session mode banner - renders in-flow, projects to nav on scroll */}
<ContentBannerSlot
stickyOffset={STICKY_HEADER_OFFSET}
className={css({ marginBottom: '1rem' })}
style={{
opacity: contentOpacity,
transition: contentTransition,
}}
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
padding: { base: '0.75rem', sm: '1rem', md: '1.5rem' },
})}
>
<div className={css({ maxWidth: '900px', margin: '0 auto' })}>
{/* Session mode banner - renders in-flow, projects to nav on scroll */}
<ContentBannerSlot
stickyOffset={STICKY_HEADER_OFFSET}
className={css({ marginBottom: '1rem' })}
/>
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} isDark={isDark} />
{activeTab === 'overview' && (
<OverviewTab
student={selectedStudent}
currentPhase={currentPhase}
skillHealth={skillHealth}
onStartPractice={handleStartPractice}
/>
)}
{activeTab === 'skills' && (
<SkillsTab
skills={liveSkills}
problemHistory={problemHistory}
recentSessions={recentSessions}
isDark={isDark}
onManageSkills={() => setShowManualSkillModal(true)}
studentId={studentId}
/>
)}
{activeTab === 'history' && <HistoryTab isDark={isDark} studentId={studentId} />}
{activeTab === 'notes' && (
<NotesTab
isDark={isDark}
notes={currentNotes}
studentName={player.name}
playerId={player.id}
onNotesSaved={setCurrentNotes}
/>
)}
</div>
{/* Modals */}
<ManualSkillSelector
studentName={player.name}
playerId={player.id}
open={showManualSkillModal}
onClose={() => setShowManualSkillModal(false)}
onSave={handleSaveManualSkills}
currentMasteredSkills={liveSkills.filter((s) => s.isPracticing).map((s) => s.skillId)}
skillMasteryData={liveSkills}
bktResultsMap={bktResultsMap}
/>
</main>
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} isDark={isDark} />
{showStartPracticeModal && sessionMode && (
<StartPracticeModal
studentId={studentId}
studentName={player.name}
focusDescription={sessionMode.focusDescription}
sessionMode={sessionMode}
avgSecondsPerProblem={avgSecondsPerProblem}
existingPlan={activeSession}
problemHistory={problemHistory}
onClose={() => setShowStartPracticeModal(false)}
onStarted={() => setShowStartPracticeModal(false)}
/>
)}
{activeTab === 'overview' && (
<OverviewTab
student={selectedStudent}
currentPhase={currentPhase}
skillHealth={skillHealth}
onStartPractice={handleStartPractice}
/>
)}
{activeTab === 'skills' && (
<SkillsTab
skills={liveSkills}
problemHistory={problemHistory}
recentSessions={recentSessions}
isDark={isDark}
onManageSkills={() => setShowManualSkillModal(true)}
studentId={studentId}
/>
)}
{activeTab === 'history' && <HistoryTab isDark={isDark} studentId={studentId} />}
{activeTab === 'notes' && (
<NotesTab
isDark={isDark}
notes={currentNotes}
studentName={player.name}
playerId={player.id}
onNotesSaved={setCurrentNotes}
/>
)}
</div>
{/* Modals */}
<ManualSkillSelector
studentName={player.name}
playerId={player.id}
open={showManualSkillModal}
onClose={() => setShowManualSkillModal(false)}
onSave={handleSaveManualSkills}
currentMasteredSkills={liveSkills.filter((s) => s.isPracticing).map((s) => s.skillId)}
skillMasteryData={liveSkills}
bktResultsMap={bktResultsMap}
/>
</main>
{showStartPracticeModal && sessionMode && (
<StartPracticeModal
studentId={studentId}
studentName={player.name}
focusDescription={sessionMode.focusDescription}
sessionMode={sessionMode}
avgSecondsPerProblem={avgSecondsPerProblem}
existingPlan={activeSession}
problemHistory={problemHistory}
onClose={() => setShowStartPracticeModal(false)}
onStarted={() => setShowStartPracticeModal(false)}
/>
)}
{/* Session Observer Modal */}
{isObserving && activeSession && (
<SessionObserverModal
isOpen={isObserving}
onClose={() => setIsObserving(false)}
session={{
sessionId: activeSession.id,
playerId: studentId,
startedAt:
typeof activeSession.createdAt === 'string'
? activeSession.createdAt
: activeSession.createdAt instanceof Date
? activeSession.createdAt.toISOString()
: new Date().toISOString(),
currentPartIndex: activeSession.currentPartIndex ?? 0,
currentSlotIndex: activeSession.currentSlotIndex ?? 0,
totalParts: activeSession.parts?.length ?? 1,
totalProblems: activeSession.parts?.reduce((sum, p) => sum + p.slots.length, 0) ?? 0,
completedProblems:
activeSession.results?.filter((r) => r.isCorrect !== null).length ?? 0,
}}
student={{
name: player.name,
emoji: player.emoji,
color: player.color,
}}
observerId={userId}
/>
)}
{/* Session Observer Modal */}
{isObserving && activeSession && (
<SessionObserverModal
isOpen={isObserving}
onClose={() => setIsObserving(false)}
session={{
sessionId: activeSession.id,
playerId: studentId,
startedAt:
typeof activeSession.createdAt === 'string'
? activeSession.createdAt
: activeSession.createdAt instanceof Date
? activeSession.createdAt.toISOString()
: new Date().toISOString(),
currentPartIndex: activeSession.currentPartIndex ?? 0,
currentSlotIndex: activeSession.currentSlotIndex ?? 0,
totalParts: activeSession.parts?.length ?? 1,
totalProblems:
activeSession.parts?.reduce((sum, p) => sum + p.slots.length, 0) ?? 0,
completedProblems:
activeSession.results?.filter((r) => r.isCorrect !== null).length ?? 0,
}}
student={{
name: player.name,
emoji: player.emoji,
color: player.color,
}}
observerId={userId}
/>
)}
</PracticeErrorBoundary>
</PageWithNav>
</SessionModeBannerProvider>

View File

@ -179,7 +179,14 @@ export function PracticeSubNav({
}
: undefined,
}
}, [student.id, student.name, student.isArchived, viewerRelationship, hasActiveSession, activeSession?.id])
}, [
student.id,
student.name,
student.isArchived,
viewerRelationship,
hasActiveSession,
activeSession?.id,
])
// Use student actions hook for menu logic
const { actions, handlers, modals, classrooms } = useStudentActions(studentActionData, {
@ -515,7 +522,7 @@ export function PracticeSubNav({
sideOffset={8}
align="end"
>
{/* Go to Dashboard - when not on dashboard */}
{/* Go to Dashboard - when not on dashboard */}
{!isOnDashboard && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
@ -635,16 +642,18 @@ export function PracticeSubNav({
)}
{/* Show enroll option even if no enrollments yet */}
{classrooms.enrolled.length === 0 && !classrooms.current && actions.enrollInClassroom && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.openEnrollModal}
data-action="enroll-in-classroom"
>
<span>{ACTION_DEFINITIONS.enrollInClassroom.icon}</span>
<span>{ACTION_DEFINITIONS.enrollInClassroom.label}</span>
</DropdownMenu.Item>
)}
{classrooms.enrolled.length === 0 &&
!classrooms.current &&
actions.enrollInClassroom && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.openEnrollModal}
data-action="enroll-in-classroom"
>
<span>{ACTION_DEFINITIONS.enrollInClassroom.icon}</span>
<span>{ACTION_DEFINITIONS.enrollInClassroom.label}</span>
</DropdownMenu.Item>
)}
<DropdownMenu.Separator className={separatorStyles(isDark)} />

View File

@ -112,12 +112,7 @@ export function RelationshipCard({ playerId, className, compact = false }: Relat
>
{/* Parents Section */}
{stakeholders.parents.length > 0 && (
<StakeholderSection
title="Parents"
icon="👪"
isDark={isDark}
compact={compact}
>
<StakeholderSection title="Parents" icon="👪" isDark={isDark} compact={compact}>
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '6px' })}>
{stakeholders.parents.map((parent) => (
<ParentBadge key={parent.id} parent={parent} isDark={isDark} compact={compact} />
@ -128,12 +123,7 @@ export function RelationshipCard({ playerId, className, compact = false }: Relat
{/* Classrooms Section */}
{stakeholders.enrolledClassrooms.length > 0 && (
<StakeholderSection
title="Classrooms"
icon="🏫"
isDark={isDark}
compact={compact}
>
<StakeholderSection title="Classrooms" icon="🏫" isDark={isDark} compact={compact}>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '6px' })}>
{stakeholders.enrolledClassrooms.map((classroom) => (
<ClassroomRow
@ -150,12 +140,7 @@ export function RelationshipCard({ playerId, className, compact = false }: Relat
{/* Pending Enrollments */}
{stakeholders.pendingEnrollments.length > 0 && (
<StakeholderSection
title="Pending"
icon="⌛"
isDark={isDark}
compact={compact}
>
<StakeholderSection title="Pending" icon="⌛" isDark={isDark} compact={compact}>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '6px' })}>
{stakeholders.pendingEnrollments.map((pending) => (
<PendingRow key={pending.id} pending={pending} isDark={isDark} compact={compact} />
@ -210,7 +195,12 @@ function ViewerRelationshipSection({
? `Enrolled in ${relationship.classroomName}`
: 'Enrolled in your classroom',
color: {
light: { bg: 'purple.50', border: 'purple.200', text: 'purple.700', accent: 'purple.600' },
light: {
bg: 'purple.50',
border: 'purple.200',
text: 'purple.700',
accent: 'purple.600',
},
dark: {
bg: 'purple.900/40',
border: 'purple.700',
@ -225,7 +215,12 @@ function ViewerRelationshipSection({
title: 'Visiting Student',
subtitle: presence ? `Present in ${presence.classroomName}` : 'In your classroom',
color: {
light: { bg: 'emerald.50', border: 'emerald.200', text: 'emerald.700', accent: 'emerald.600' },
light: {
bg: 'emerald.50',
border: 'emerald.200',
text: 'emerald.700',
accent: 'emerald.600',
},
dark: {
bg: 'emerald.900/40',
border: 'emerald.700',

View File

@ -154,12 +154,7 @@ export function RelationshipIndicator({
)}
>
{badges.map((badge) => (
<CompactBadge
key={badge.key}
badge={badge}
isDark={isDark}
showTooltip={showTooltip}
/>
<CompactBadge key={badge.key} badge={badge} isDark={isDark} showTooltip={showTooltip} />
))}
</div>
)
@ -254,13 +249,7 @@ function CompactBadge({
/**
* Full badge: icon + label
*/
function FullBadge({
badge,
isDark,
}: {
badge: RelationshipBadge
isDark: boolean
}) {
function FullBadge({ badge, isDark }: { badge: RelationshipBadge; isDark: boolean }) {
const colors = isDark ? badge.color.dark : badge.color.light
return (

View File

@ -24,7 +24,12 @@ export { NumericKeypad } from './NumericKeypad'
export { PracticeErrorBoundary } from './PracticeErrorBoundary'
export { PracticeFeedback } from './PracticeFeedback'
export type { RelationshipBadgeProps, RelationshipConfig } from './RelationshipBadge'
export { RelationshipBadge, RelationshipSummary, RELATIONSHIP_CONFIGS, getRelationType } from './RelationshipBadge'
export {
RelationshipBadge,
RelationshipSummary,
RELATIONSHIP_CONFIGS,
getRelationType,
} from './RelationshipBadge'
export type { RelationshipCardProps } from './RelationshipCard'
export { RelationshipCard } from './RelationshipCard'
export { PurposeBadge } from './PurposeBadge'

View File

@ -2,11 +2,7 @@
import { useMemo } from 'react'
import type { StudentRelationship, EnrollmentStatus } from '@/types/student'
import {
useMyClassroom,
useEnrolledStudents,
useClassroomPresence,
} from '@/hooks/useClassroom'
import { useMyClassroom, useEnrolledStudents, useClassroomPresence } from '@/hooks/useClassroom'
import { usePlayersWithSkillData } from '@/hooks/useUserPlayers'
/**