feat(practice): add Needs Attention to unified compact layout

- Integrate "Needs Attention" section into unified compacting system
- Single attention student flows with other compact sections
- Multiple attention students get full sticky header
- Add comprehensive Storybook stories for Needs Attention scenarios:
  - NeedsAttentionSingle, NeedsAttentionMultiple
  - NeedsAttentionWithCompactBuckets, NeedsAttentionFullThenCompact
  - NeedsAttentionRealistic, NeedsAttentionDarkMode
- Update GroupedStudentsDemo to use unified renderItems array

🤖 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-30 12:11:44 -06:00
parent 0e7f3265fe
commit 8727782e45
2 changed files with 989 additions and 408 deletions

View File

@@ -461,67 +461,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
{/* Shows for anyone with children, even if they're also a teacher */}
<EntryPromptBanner />
{/* Needs Attention Section - uses same bucket styling as other sections */}
{studentsNeedingAttention.length > 0 && (
<div data-bucket="attention" data-component="needs-attention-bucket">
{/* Bucket header - sticky below filter bar */}
<h2
data-element="bucket-header"
className={css({
position: 'sticky',
top: '160px', // Nav (80px) + Filter bar (~80px)
zIndex: Z_INDEX.STICKY_BUCKET_HEADER,
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'orange.400' : 'orange.600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.300',
bg: isDark ? 'gray.900' : 'gray.50',
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
>
<span></span>
<span>Needs Attention</span>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '20px',
height: '20px',
padding: '0 6px',
borderRadius: '10px',
backgroundColor: isDark ? 'orange.700' : 'orange.500',
color: 'white',
fontSize: '0.75rem',
fontWeight: 'bold',
})}
>
{studentsNeedingAttention.length}
</span>
</h2>
{/* Student cards - intervention badges will show on each card */}
<StudentSelector
students={studentsNeedingAttention as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
/>
</div>
)}
{/* Grouped Student List */}
{/* All Students - unified layout with compact sections flowing together */}
{filteredGroupedStudents.length === 0 && studentsNeedingAttention.length === 0 ? (
<ViewEmptyState
currentView={currentView}
@@ -542,204 +482,456 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
gap: '24px',
})}
>
{filteredGroupedStudents.map((bucket) => (
<div key={bucket.bucket} data-bucket={bucket.bucket}>
{/* Bucket header - sticky below filter bar */}
<h2
data-element="bucket-header"
className={css({
position: 'sticky',
top: '160px', // Nav (80px) + Filter bar (~80px)
zIndex: Z_INDEX.STICKY_BUCKET_HEADER,
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
bg: isDark ? 'gray.900' : 'gray.50',
})}
>
{bucket.bucketName}
</h2>
{(() => {
// Unified section type for both "Needs Attention" and regular buckets
type Section =
| {
type: 'attention'
students: typeof studentsNeedingAttention
}
| {
type: 'bucket'
bucket: (typeof filteredGroupedStudents)[0]
}
{/* Categories within bucket - grouped for compact display */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
{(() => {
// Group consecutive compact categories (1 student, no attention placeholder)
type RenderItem =
| { type: 'compact-row'; categories: typeof bucket.categories }
| { type: 'full'; category: (typeof bucket.categories)[0] }
// Build list of all sections (attention first, then buckets)
const allSections: Section[] = []
if (studentsNeedingAttention.length > 0) {
allSections.push({ type: 'attention', students: studentsNeedingAttention })
}
for (const bucket of filteredGroupedStudents) {
allSections.push({ type: 'bucket', bucket })
}
const items: RenderItem[] = []
let compactBuffer: typeof bucket.categories = []
// Helper to check if a category is compact (1 student, no attention placeholder)
const isCategoryCompact = (
bucket: (typeof filteredGroupedStudents)[0],
cat: (typeof bucket.categories)[0]
) => {
const attentionCount =
attentionCountsByBucket.get(bucket.bucket)?.get(cat.category) ?? 0
return cat.students.length === 1 && attentionCount === 0
}
for (const cat of bucket.categories) {
const attentionCount =
attentionCountsByBucket.get(bucket.bucket)?.get(cat.category) ?? 0
const isCompact = cat.students.length === 1 && attentionCount === 0
// Helper to check if entire bucket is compact
const isBucketCompact = (bucket: (typeof filteredGroupedStudents)[0]) =>
bucket.categories.every((cat) => isCategoryCompact(bucket, cat))
if (isCompact) {
compactBuffer.push(cat)
} else {
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
compactBuffer = []
}
items.push({ type: 'full', category: cat })
}
}
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
}
// Helper to check if a section is compact
const isSectionCompact = (section: Section) => {
if (section.type === 'attention') {
return section.students.length === 1
}
return isBucketCompact(section.bucket)
}
return items.map((item, idx) => {
if (item.type === 'compact-row') {
// Render compact categories flowing together
return (
<div
key={`compact-${idx}`}
data-element="compact-category-row"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
alignItems: 'flex-start',
})}
>
{item.categories.map((cat) => (
<div
key={cat.category ?? 'null'}
data-category={cat.category ?? 'new'}
data-compact="true"
// Group consecutive compact sections
type RenderItem =
| { type: 'compact-sections'; sections: Section[] }
| { type: 'full-section'; section: Section }
const renderItems: RenderItem[] = []
let compactBuffer: Section[] = []
for (const section of allSections) {
if (isSectionCompact(section)) {
compactBuffer.push(section)
} else {
if (compactBuffer.length > 0) {
renderItems.push({ type: 'compact-sections', sections: compactBuffer })
compactBuffer = []
}
renderItems.push({ type: 'full-section', section })
}
}
if (compactBuffer.length > 0) {
renderItems.push({ type: 'compact-sections', sections: compactBuffer })
}
return renderItems.map((item, itemIdx) => {
if (item.type === 'compact-sections') {
// Render compact sections flowing together
return (
<div
key={`compact-sections-${itemIdx}`}
data-element="compact-sections-row"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
alignItems: 'flex-start',
})}
>
{item.sections.flatMap((section) => {
if (section.type === 'attention') {
// Compact attention section (1 student)
return (
<div
key="attention"
data-bucket="attention"
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2px',
})}
>
<span
data-element="compact-label"
className={css({
fontSize: '0.6875rem',
fontWeight: 'medium',
color: isDark ? 'orange.400' : 'orange.500',
paddingLeft: '4px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
alignItems: 'center',
})}
>
{/* Small inline category label */}
<span></span>
<span
data-element="compact-category-label"
className={css({
fontSize: '0.75rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
paddingLeft: '4px',
textTransform: 'uppercase',
letterSpacing: '0.03em',
})}
>
{cat.categoryName}
Needs Attention
</span>
{/* Single student tile */}
<StudentSelector
students={cat.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
compact
/>
</div>
))}
</div>
)
}
// Render full category (2+ students or has attention placeholder)
const category = item.category
const attentionCount =
attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ?? 0
return (
<div
key={category.category ?? 'null'}
data-category={category.category ?? 'new'}
>
{/* Category header - sticky below bucket header */}
<h3
data-element="category-header"
className={css({
position: 'sticky',
top: '195px', // Nav (80px) + Filter bar (~80px) + Bucket header (~35px)
zIndex: Z_INDEX.STICKY_CATEGORY_HEADER,
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
bg: isDark ? 'gray.900' : 'gray.50',
})}
>
{category.categoryName}
</h3>
{/* Student cards wrapper */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'stretch',
})}
>
{/* Student cards */}
{category.students.length > 0 && (
</span>
<StudentSelector
students={category.students as StudentWithProgress[]}
students={section.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
compact
/>
)}
{/* Attention placeholder */}
{attentionCount > 0 && (
<div
data-element="attention-placeholder"
data-attention-count={attentionCount}
</div>
)
}
// Compact bucket (all single-student categories)
return section.bucket.categories.map((cat) => (
<div
key={`${section.bucket.bucket}-${cat.category ?? 'null'}`}
data-bucket={section.bucket.bucket}
data-category={cat.category ?? 'new'}
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2px',
})}
>
<span
data-element="compact-label"
className={css({
fontSize: '0.6875rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
paddingLeft: '4px',
display: 'flex',
gap: '4px',
alignItems: 'center',
})}
>
<span
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 16px',
borderRadius: '8px',
border: '2px dashed',
borderColor: isDark ? 'orange.700' : 'orange.300',
color: isDark ? 'orange.400' : 'orange.600',
fontSize: '0.8125rem',
textAlign: 'center',
minHeight: '60px',
flexShrink: 0,
textTransform: 'uppercase',
letterSpacing: '0.03em',
color: isDark ? 'gray.600' : 'gray.350',
})}
>
+{attentionCount} in Needs Attention
</div>
)}
{section.bucket.bucketName}
</span>
<span className={css({ color: isDark ? 'gray.600' : 'gray.300' })}>
·
</span>
<span>{cat.categoryName}</span>
</span>
<StudentSelector
students={cat.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
compact
/>
</div>
</div>
)
})
})()}
</div>
</div>
))}
))
})}
</div>
)
}
// Full section
const section = item.section
if (section.type === 'attention') {
// Full attention section (multiple students)
return (
<div
key="attention"
data-bucket="attention"
data-component="needs-attention-bucket"
>
<h2
data-element="bucket-header"
className={css({
position: 'sticky',
top: '160px',
zIndex: Z_INDEX.STICKY_BUCKET_HEADER,
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'orange.400' : 'orange.600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.300',
bg: isDark ? 'gray.900' : 'gray.50',
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
>
<span></span>
<span>Needs Attention</span>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '20px',
height: '20px',
padding: '0 6px',
borderRadius: '10px',
backgroundColor: isDark ? 'orange.700' : 'orange.500',
color: 'white',
fontSize: '0.75rem',
fontWeight: 'bold',
})}
>
{section.students.length}
</span>
</h2>
<StudentSelector
students={section.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
/>
</div>
)
}
// Full bucket
const bucket = section.bucket
return (
<div key={bucket.bucket} data-bucket={bucket.bucket}>
<h2
data-element="bucket-header"
className={css({
position: 'sticky',
top: '160px',
zIndex: Z_INDEX.STICKY_BUCKET_HEADER,
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
bg: isDark ? 'gray.900' : 'gray.50',
})}
>
{bucket.bucketName}
</h2>
{/* Categories within bucket - grouped for compact display */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
{(() => {
// Group consecutive compact categories
type CategoryRenderItem =
| { type: 'compact-row'; categories: typeof bucket.categories }
| { type: 'full'; category: (typeof bucket.categories)[0] }
const items: CategoryRenderItem[] = []
let compactBuffer: typeof bucket.categories = []
for (const cat of bucket.categories) {
if (isCategoryCompact(bucket, cat)) {
compactBuffer.push(cat)
} else {
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
compactBuffer = []
}
items.push({ type: 'full', category: cat })
}
}
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
}
return items.map((item, idx) => {
if (item.type === 'compact-row') {
// Render compact categories flowing together
return (
<div
key={`compact-${idx}`}
data-element="compact-category-row"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
alignItems: 'flex-start',
})}
>
{item.categories.map((cat) => (
<div
key={cat.category ?? 'null'}
data-category={cat.category ?? 'new'}
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
{/* Small inline category label */}
<span
data-element="compact-category-label"
className={css({
fontSize: '0.75rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
paddingLeft: '4px',
})}
>
{cat.categoryName}
</span>
{/* Single student tile */}
<StudentSelector
students={cat.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
compact
/>
</div>
))}
</div>
)
}
// Render full category (2+ students or has attention placeholder)
const category = item.category
const attentionCount =
attentionCountsByBucket.get(bucket.bucket)?.get(category.category) ??
0
return (
<div
key={category.category ?? 'null'}
data-category={category.category ?? 'new'}
>
{/* Category header - sticky below bucket header */}
<h3
data-element="category-header"
className={css({
position: 'sticky',
top: '195px', // Nav (80px) + Filter bar (~80px) + Bucket header (~35px)
zIndex: Z_INDEX.STICKY_CATEGORY_HEADER,
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
bg: isDark ? 'gray.900' : 'gray.50',
})}
>
{category.categoryName}
</h3>
{/* Student cards wrapper */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'stretch',
})}
>
{/* Student cards */}
{category.students.length > 0 && (
<StudentSelector
students={category.students as StudentWithProgress[]}
onSelectStudent={handleSelectStudent}
onToggleSelection={handleToggleSelection}
onObserveSession={handleObserveSession}
title=""
selectedIds={selectedIds}
hideAddButton
/>
)}
{/* Attention placeholder */}
{attentionCount > 0 && (
<div
data-element="attention-placeholder"
data-attention-count={attentionCount}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 16px',
borderRadius: '8px',
border: '2px dashed',
borderColor: isDark ? 'orange.700' : 'orange.300',
color: isDark ? 'orange.400' : 'orange.600',
fontSize: '0.8125rem',
textAlign: 'center',
minHeight: '60px',
flexShrink: 0,
})}
>
+{attentionCount} in Needs Attention
</div>
)}
</div>
</div>
)
})
})()}
</div>
</div>
)
})
})()}
</div>
)}

View File

@@ -98,11 +98,351 @@ interface BucketData {
// Component that replicates the exact structure from PracticeClient.tsx
function GroupedStudentsDemo({
buckets,
needsAttentionStudents = [],
isDark = false,
}: {
buckets: BucketData[]
needsAttentionStudents?: StudentWithProgress[]
isDark?: boolean
}) {
// Build unified sections list (attention first, then buckets)
type Section =
| { type: 'attention'; students: StudentWithProgress[] }
| { type: 'bucket'; bucket: BucketData }
const allSections: Section[] = []
if (needsAttentionStudents.length > 0) {
allSections.push({ type: 'attention', students: needsAttentionStudents })
}
for (const bucket of buckets) {
allSections.push({ type: 'bucket', bucket })
}
// Helper to check if a category is compact
const isCategoryCompact = (cat: CategoryData) =>
cat.students.length === 1 && (cat.attentionCount ?? 0) === 0
// Helper to check if a bucket is compact
const isBucketCompact = (bucket: BucketData) =>
bucket.categories.every((cat) => isCategoryCompact(cat))
// Helper to check if a section is compact
const isSectionCompact = (section: Section) => {
if (section.type === 'attention') {
return section.students.length === 1
}
return isBucketCompact(section.bucket)
}
// Group consecutive compact sections
type RenderItem =
| { type: 'compact-sections'; sections: Section[] }
| { type: 'full-section'; section: Section }
const renderItems: RenderItem[] = []
let compactBuffer: Section[] = []
for (const section of allSections) {
if (isSectionCompact(section)) {
compactBuffer.push(section)
} else {
if (compactBuffer.length > 0) {
renderItems.push({ type: 'compact-sections', sections: compactBuffer })
compactBuffer = []
}
renderItems.push({ type: 'full-section', section })
}
}
if (compactBuffer.length > 0) {
renderItems.push({ type: 'compact-sections', sections: compactBuffer })
}
// Helper to render a compact section (single attention or compact bucket)
const renderCompactSection = (section: Section, idx: number) => {
if (section.type === 'attention') {
// Single attention student - compact with label
const student = section.students[0]
return (
<div
key={`attention-compact-${idx}`}
data-section="needs-attention"
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
<span
data-element="compact-label"
className={css({
fontSize: '0.6875rem',
fontWeight: 'medium',
color: isDark ? 'orange.400' : 'orange.500',
paddingLeft: '4px',
})}
>
<span className={css({ textTransform: 'uppercase' })}>Needs Attention</span>
</span>
<StudentSelector
students={[student]}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
</div>
)
}
// Compact bucket - all categories are single-student
const bucket = section.bucket
return bucket.categories.map((cat, catIdx) => (
<div
key={`${bucket.bucket}-${cat.category}-${catIdx}`}
data-bucket={bucket.bucket}
data-category={cat.category}
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
<span
data-element="compact-label"
className={css({
fontSize: '0.6875rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
paddingLeft: '4px',
})}
>
<span className={css({ textTransform: 'uppercase' })}>{bucket.bucketName}</span>
<span className={css({ margin: '0 4px' })}>·</span>
<span>{cat.categoryName}</span>
</span>
<StudentSelector
students={cat.students}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
</div>
))
}
// Helper to render a full section (multiple attention or non-compact bucket)
const renderFullSection = (section: Section) => {
if (section.type === 'attention') {
// Full Needs Attention section
return (
<div key="needs-attention-full" data-section="needs-attention">
<h2
data-element="attention-header"
className={css({
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'orange.400' : 'orange.600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'orange.700' : 'orange.200',
})}
>
Needs Attention
</h2>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
})}
>
<StudentSelector
students={section.students}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
</div>
</div>
)
}
// Full bucket with categories
const bucket = section.bucket
return (
<div key={bucket.bucket} data-bucket={bucket.bucket}>
<h2
data-element="bucket-header"
className={css({
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{bucket.bucketName}
</h2>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
{(() => {
// Group consecutive compact categories within the bucket
type CatRenderItem =
| { type: 'compact-row'; categories: CategoryData[] }
| { type: 'full'; category: CategoryData }
const items: CatRenderItem[] = []
let compactBuffer: CategoryData[] = []
for (const cat of bucket.categories) {
if (isCategoryCompact(cat)) {
compactBuffer.push(cat)
} else {
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
compactBuffer = []
}
items.push({ type: 'full', category: cat })
}
}
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
}
return items.map((item, idx) => {
if (item.type === 'compact-row') {
return (
<div
key={`compact-${idx}`}
data-element="compact-category-row"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
alignItems: 'flex-start',
})}
>
{item.categories.map((cat) => (
<div
key={cat.category}
data-category={cat.category}
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
<span
data-element="compact-category-label"
className={css({
fontSize: '0.75rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
paddingLeft: '4px',
})}
>
{cat.categoryName}
</span>
<StudentSelector
students={cat.students}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
</div>
))}
</div>
)
}
const category = item.category
return (
<div key={category.category} data-category={category.category}>
<h3
data-element="category-header"
className={css({
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
})}
>
{category.categoryName}
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'stretch',
})}
>
<StudentSelector
students={category.students}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
{(category.attentionCount ?? 0) > 0 && (
<div
data-element="attention-placeholder"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 16px',
borderRadius: '8px',
border: '2px dashed',
borderColor: isDark ? 'orange.700' : 'orange.300',
color: isDark ? 'orange.400' : 'orange.600',
fontSize: '0.8125rem',
textAlign: 'center',
minHeight: '120px',
minWidth: '150px',
})}
>
+{category.attentionCount} in Needs Attention
</div>
)}
</div>
</div>
)
})
})()}
</div>
</div>
)
}
return (
<div
className={css({
@@ -120,182 +460,26 @@ function GroupedStudentsDemo({
gap: '24px',
})}
>
{buckets.map((bucket) => (
<div key={bucket.bucket} data-bucket={bucket.bucket}>
{/* Bucket header (e.g., "OLDER", "TODAY") */}
<h2
data-element="bucket-header"
className={css({
fontSize: '0.875rem',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '12px',
paddingTop: '8px',
paddingBottom: '8px',
borderBottom: '2px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{bucket.bucketName}
</h2>
{/* Categories within bucket - grouped for compact display */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
{(() => {
// Group consecutive compact categories (1 student, no attention placeholder)
type RenderItem =
| { type: 'compact-row'; categories: CategoryData[] }
| { type: 'full'; category: CategoryData }
const items: RenderItem[] = []
let compactBuffer: CategoryData[] = []
for (const cat of bucket.categories) {
const isCompact = cat.students.length === 1 && (cat.attentionCount ?? 0) === 0
if (isCompact) {
compactBuffer.push(cat)
} else {
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
compactBuffer = []
}
items.push({ type: 'full', category: cat })
}
}
if (compactBuffer.length > 0) {
items.push({ type: 'compact-row', categories: compactBuffer })
}
return items.map((item, idx) => {
if (item.type === 'compact-row') {
// Render compact categories flowing together
return (
<div
key={`compact-${idx}`}
data-element="compact-category-row"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
alignItems: 'flex-start',
})}
>
{item.categories.map((cat) => (
<div
key={cat.category}
data-category={cat.category}
data-compact="true"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4px',
})}
>
{/* Small inline category label */}
<span
data-element="compact-category-label"
className={css({
fontSize: '0.75rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
paddingLeft: '4px',
})}
>
{cat.categoryName}
</span>
{/* Single student tile */}
<StudentSelector
students={cat.students}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
</div>
))}
</div>
)
}
// Render full category (2+ students or has attention placeholder)
const category = item.category
return (
<div key={category.category} data-category={category.category}>
{/* Category header (sticky in real app) */}
<h3
data-element="category-header"
className={css({
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'gray.500' : 'gray.400',
marginBottom: '8px',
paddingTop: '4px',
paddingBottom: '4px',
paddingLeft: '4px',
})}
>
{category.categoryName}
</h3>
{/* Student cards wrapper */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'stretch',
})}
>
{/* Student cards - NOT compact for multi-student */}
<StudentSelector
students={category.students}
onSelectStudent={() => {}}
onToggleSelection={() => {}}
title=""
hideAddButton
compact
/>
{/* Attention placeholder */}
{(category.attentionCount ?? 0) > 0 && (
<div
data-element="attention-placeholder"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px 16px',
borderRadius: '8px',
border: '2px dashed',
borderColor: isDark ? 'orange.700' : 'orange.300',
color: isDark ? 'orange.400' : 'orange.600',
fontSize: '0.8125rem',
textAlign: 'center',
minHeight: '120px',
minWidth: '150px',
})}
>
+{category.attentionCount} in Needs Attention
</div>
)}
</div>
</div>
)
})
})()}
</div>
</div>
))}
{renderItems.map((item, idx) => {
if (item.type === 'compact-sections') {
// Render compact sections flowing together
return (
<div
key={`compact-sections-${idx}`}
data-element="compact-sections-row"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
alignItems: 'flex-start',
})}
>
{item.sections.map((section, sIdx) => renderCompactSection(section, sIdx))}
</div>
)
}
return renderFullSection(item.section)
})}
</div>
</div>
)
@@ -622,3 +806,208 @@ export const RealisticScenario: Story = {
/>
),
}
// =============================================================================
// NEEDS ATTENTION STORIES
// =============================================================================
/**
* Single student needing attention - renders compact, flows with other compact sections
*/
export const NeedsAttentionSingle: Story = {
render: () => (
<GroupedStudentsDemo
needsAttentionStudents={[students.sonia]}
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'five-comp-add',
categoryName: 'Five Complements (Add)',
students: [students.marcus],
},
{
category: 'ten-comp-sub',
categoryName: 'Ten Complements (Sub)',
students: [students.luna],
},
],
},
]}
/>
),
}
/**
* Multiple students needing attention - renders full section with sticky header
*/
export const NeedsAttentionMultiple: Story = {
render: () => (
<GroupedStudentsDemo
needsAttentionStudents={[students.sonia, students.marcus, students.luna]}
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'five-comp-add',
categoryName: 'Five Complements (Add)',
students: [students.alex],
},
],
},
]}
/>
),
}
/**
* Single attention student flows together with single-student compact buckets
*/
export const NeedsAttentionWithCompactBuckets: Story = {
render: () => (
<GroupedStudentsDemo
needsAttentionStudents={[students.sonia]}
buckets={[
{
bucket: 'today',
bucketName: 'Today',
categories: [
{
category: 'five-comp-add',
categoryName: 'Five Comp (Add)',
students: [students.marcus],
},
],
},
{
bucket: 'thisWeek',
bucketName: 'This Week',
categories: [
{
category: 'ten-comp-sub',
categoryName: 'Ten Comp (Sub)',
students: [students.luna],
},
],
},
]}
/>
),
}
/**
* Multiple attention students (full section) followed by compact buckets
*/
export const NeedsAttentionFullThenCompact: Story = {
render: () => (
<GroupedStudentsDemo
needsAttentionStudents={[students.sonia, students.marcus]}
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'five-comp-add',
categoryName: 'Five Comp (Add)',
students: [students.luna],
},
{
category: 'ten-comp-sub',
categoryName: 'Ten Comp (Sub)',
students: [students.alex],
},
],
},
]}
/>
),
}
/**
* Complete realistic scenario with attention section
*/
export const NeedsAttentionRealistic: Story = {
render: () => (
<GroupedStudentsDemo
needsAttentionStudents={[students.kai]}
buckets={[
{
bucket: 'today',
bucketName: 'Today',
categories: [
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.sonia, students.marcus],
},
],
},
{
bucket: 'thisWeek',
bucketName: 'This Week',
categories: [
{
category: 'ten-comp-sub',
categoryName: 'Ten Comp (Sub)',
students: [students.luna],
},
{
category: 'ten-comp-add',
categoryName: 'Ten Comp (Add)',
students: [students.alex],
},
],
},
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'basic-add',
categoryName: 'Basic Addition',
students: [students.maya],
},
],
},
]}
/>
),
}
/**
* Dark mode with needs attention section
*/
export const NeedsAttentionDarkMode: Story = {
render: () => (
<GroupedStudentsDemo
isDark
needsAttentionStudents={[students.sonia, students.marcus]}
buckets={[
{
bucket: 'older',
bucketName: 'Older',
categories: [
{
category: 'five-comp-add',
categoryName: 'Five Complements (Addition)',
students: [students.luna],
},
{
category: 'ten-comp-sub',
categoryName: 'Ten Complements (Subtraction)',
students: [students.alex],
},
],
},
]}
/>
),
parameters: {
backgrounds: { default: 'dark' },
},
}