Roster drawer — scope & status

The pool tabs answer two different questions with one control, and the answer only ever fits one of the two populations. This board separates the questions, then compares eight ways to put them back on screen at 340 px — against six ways to survive a list that won't fit, and four ways to stop one section burying the other.
saga-dash · pod-board · option matrix · v2 · 2026-09-22
The diagnosis

One enum, two dimensions

RosterPool = 'unassigned' | 'assigned' | 'all' | 'program' — three status values and one scope value, sharing one tab row. Picking a scope silently discards the status, and picking a status silently discards the scope. Everything below follows from splitting them.

filterTutorsByPool is scar tissue from the conflation

The sessions adapter folds every program tutor into what it calls the period universe. Tutors are already program-scoped in all four tabs — the flag exists only to hand them back a status filter after the scope half was made meaningless for them. Under scope × status it dissolves into a default.

day-pod-board-adapter.ts:186 · store.svelte.ts:402

Two meanings of "not assigned", one panel

The tab says Unassigned = not on this board. Grouping says The Rest = no assignment anywhere today. A student placed in another period is in the first and not the second. The code documents the collision rather than resolving it.

roster-grouping.ts:96-98

Students and tutors don't want the same words

A student is placed once; "unassigned" is a gap to fill. A tutor is reusable across rotations and legitimately carries many placements; "unassigned" is closer to "has capacity". One label cannot mean both — which is why the tutor section already opts out of the filter by default.

The four scopes are not the same kind of thing
ScopeHow it resolvesCost
Group
period cohort
client filter over loaded candidates.periodfree
Programclient filter over candidates.program, capped at PROGRAM_ROSTER_MAX = 1000free
Group-scoped
tutors
no data edge exists. Cohort is derived from section assignment; tutors aren't sectionedbackend
DistrictcreateDistrictRosterPageQuery — 50 rows/page, server-side search and rolea fetch

District has no eager list and no count. It can never be a fifth tab with a badge — it is a search, structurally unlike its neighbours. Models below differ mostly in how honestly they admit that.

The comparison

Live option matrix

Three independent axes. Control model decides where scope and status live. List scaling decides what happens when one list stops fitting. Sections decides whether a long Tutors list is allowed to bury Students. Every menu below is a real menu — open one and it shows the two captioned populations it actually sets.
Surface
Control model
List scaling
Sections new
Tutors in scope
Panel
Axis 1

Where scope and status live

The search / sort / group row is already three controls wide. The tab row is the only space actually available to spend — so every model is really a choice about what that row becomes. The first three are the combinations; the last five are the primitives they're built from.
ModelShapeMeasured at 340 pxHandles tutor ≠ student byDistrict fits as
M6
M3 + M4 combined new
Tab row deleted. Scope + status are real two-section menus in the icon cluster, and each section carries its own clickable prose line naming its cut — M3's sentence, at M4's position, with M4's affordance. 38 px global
+ 17 px per section
501 px of list
Both ways at once. The menu holds separate values per population; the readout states each section's own cut in its own words. a menu row that opens a search, plus a district segment in the Tutors readout only
M7
M6 + Available/All new
M6 plus a two-value tab row — Available / All, contextualised per surface. The coarse one-click on top, the fine control underneath. 70 px global
+ 17 px per section
469 px of list
Same as M6 below the tabs; the tabs themselves are deliberately shared — one gesture that means "show me what I can use". unchanged from M6 — the tabs never carry it
M8
Fixed facets new
Facet bar, but scope and status are permanent chips (never behind + Filter), icon-led rather than name-led, and a single chip can hold different values per population. + adds Period, Rotation, By tutor. 60–84 px chrome
488 px of list
icons buy ~1 chip/line
One chip, two values — ◍ Program⁄Group. Compact, but the split is carried by a separator most people won't parse unopened. a facet whose value resolves server-side — the only model where it's the same object as every other filter
M1
Scope tabs + status chips
Tab row carries scope only. Status becomes a wrapping row of toggle chips beneath it. 102 px chrome
445 px of list
search 238 px
Nothing — one vocabulary for both. Cheapest to build, keeps the ambiguity. a tab that can't carry a count
M2
Facet bar (stock)
Search input becomes FacetedSearch as shipped. Scope and status are ordinary facets; unset ones hide behind + Filter. 60–84 px chrome
488 px of list
wraps at 2 chips
A facet can be population-qualified, but the bar is one shared object — reads as one vocabulary. a facet whose value resolves server-side; locked / surfaced already model "secondary, not default"
M3
Menu cluster
Tab row deleted. Scope + status join sort + group as icon menus, with one prose readout for the whole panel. 64 px chrome
493 px of list
search 168 px
Two captioned sections per menu — the shipped D-8 pattern, verbatim. a menu row that opens a search
M4
Per-population headers
No global control. Each section header carries its own compact scope + status buttons. 38 px global
+ 26 px per section
519 px of list
Structurally. The two populations never share a word, so the question stops existing. a control on the Tutors header only — where the request actually lives
M5
Segmented + menu
Scope as a small segmented control in the tab row's place; status folded into the group menu as a third captioned section. 38 px chrome
485 px of list
search 238 px
Status yes (captioned sections), scope no (one segmented control). a + affordance on the end of the segment
Axis 3 new

Stopping one section burying the other

Independent of how either list scales. Tutors renders first, so a long tutor list pushes Students off the bottom of a 620 px scroller entirely — at 120 program tutors, Students starts roughly four screens down. Grouping can fold a section within a population; none of these four is about that. They are about the two populations themselves.
ModelBehaviourBuysCosts
C1 Both always openToday. Two sections, one scroll, Tutors first.Nothing to build; both populations visible in one glance when short.At 120+ tutors, Students is unreachable without a deliberate scroll nobody knows to make.
C2 CollapsibleA twisty on each section header folds it to a one-line header that keeps its count and its readout.Direct answer to the burial. Folding Tutors puts Students at the top instantly, and the state is legible.A fold is sticky state — a collapsed Tutors section on next open looks like an empty roster unless it clearly reads as folded.
C3 Sticky + snap-pastSection headers pin to the top as you scroll; a Students (640) ↓ pill floats at the bottom edge while the other section is off-screen.Never lose your place, and the other population is always one click away. Nothing is hidden.Two sticky layers plus a floating pill in 340 px is a lot of chrome-over-content; the pill covers the last row.
C4 One at a timeA Tutors | Students segmented switch; only the active population renders.Each population gets the full 620 px. The cleanest reading of a long list by far.You can no longer see a tutor and a student at once — and dragging one of each into a pod is the panel's core task.

C2 and C3 compose; C4 replaces both. C4 is also the only one that changes what a drag can reach, which makes it the one to test against a real pod-building session rather than a screenshot.

Axis 2

When one list stops fitting

A 340 px column about 620 px tall shows roughly 18 tutor chips or 36 student chips. Everything past that is a scroll, and past a few hundred it is a scroll nobody completes. Worth settling even if district never ships — a large program already hits it.
ModelBehaviourComfortable toWhat it costs
L1 Render allToday. Every chip painted, one scroll.~60Nothing to build. Degrades silently — no signal that you're in a list you can't finish.
L2 Collapsed sectionsReuse #1583 grouping; sections render headed and folded, with counts. Open the one you want.~250Only as good as the axis. Needs a default axis per population and a rule for which section starts open. Nearly free — the grouping engine already exists.
L3 Cap + show morePaint the first 25, then Show 25 more. Search bypasses the cap.~150Cheap and honest about size. Repeated clicking; the cap interacts badly with sort (the "first 25" changes meaning).
L4 VirtualisedWhole list logically present, a window of it painted. Placeholders are chips — same padding, same border, same height — so an arriving row lands in place instead of reflowing the list. Watch the boundary rows resolve.~2000Real engineering: drag-and-drop across a recycled window, animate:flipItem, and shift-range selection all assume live DOM. Highest risk of the six. A placeholder thinner than its chip is the classic tell — it makes the whole list jump on every scroll.
L5 Search-requiredAbove a threshold the list doesn't render. Empty state reads "Search to find a tutor", with recents pinned above it.anyKills browsing. Fine when you know the name, hostile when you're surveying capacity — and surveying capacity is half of what this panel is for.
L6 Server-pagedFetch 50, Load more. Renders “50 of 412” — which needs a count the server does not return today.anyLoading and error states inside the drawer. The count is the whole question: with it, L6 is just L3 over the wire; without it, the header can only say “50 loaded”, and Auto-Assign and select-all stop being able to mean “all”. Counting matching rows should be far cheaper than returning them — worth asking for before designing around its absence.
Inside M8 new

Four ways these facets are not the shipped facets

Stock FacetedSearch is a power-user instrument: everything hides behind + Filter until valued, every facet is one value, and every chip is a name plus a value. None of those three hold here — scope and status are needed on every visit, by people who are not power users. M8 keeps the shape (so it reads as the same tool as Roster and Sessions) and breaks four rules inside it.

1 · Fixed facets — never behind + Filter

Scope and status are always on the bar, always valued, and cannot be removed — only changed. They render in the teal locked treatment the widget already has. The + then means what it says: extra filters, not the ones you always need.

2 · Icon-led, not name-led

Scope Program costs ~112 px; ◍ Program costs ~78 px — roughly one extra chip per line, which at 340 px is the difference between two rows and three. The cost is that an icon has to be learned once. The menu behind it spells the field out in full, so the icon is a shorthand rather than the only statement.

3 · One chip, two populations

◍ Program⁄Group is one facet holding tutors=program and students=group. The menu is two captioned sections, D-8 verbatim. This is the rule that makes the facet bar able to express what the tabs never could — and the one most likely to be misread when closed, since a slash is doing a lot of work.

4 · A facet whose options depend on another facet

Rotation is only meaningful once a period is chosen, so it must be offerable-but-inert until then — greyed with the reason stated, not hidden (hiding it means nobody learns it exists). We have not built a dependent facet before. Open the + menu in M8 to see the shape.

Facets worth having beyond scope & status
Periodthe cohort a person's sections place them in. Exists in the data today (periodGroups / memberCohortIds) — for students. Tutors have no such edge.
Rotationdependent — inert until Period is set. New widget capability.
By tutor
students only
"show me the students assigned to Sana" — answers a question the panel is asked constantly and currently can't express at all. Derivable from the placement index the markers already read.
School / Sectionalready server-side params on the district roster query. Free if district ever becomes a scope.

The styling question is a real fork, not a detail. Keeping the shipped look says "this is the same search you use on Roster" — worth a lot for recognition. Diverging says "this is not the power-user instrument, it is the main control of this panel" — worth a lot for a first-time user who needs scope on every single visit. M8 renders the conservative end; the fixed chips are already tinted differently, which may be as far as divergence needs to go.

The word problem

Fixed frames, named out loud

Status stops being one three-way toggle and becomes a small set of independent, surface-fixed predicates whose labels name their own frame. The user never picks a frame; the word never travels to a surface where it means something else.
Program Config

Editing the persistent pods of one group. Nothing is about "today".

Unplaced herenot in a pod on this board
In another groupplaced in a different cohort of this period

No "today" frame exists on this surface — offering one would be a lie.

Day pod editor

One session, one day. The richest frame set, and where today's single word does the most damage.

Not in this podthe literal board
Free todayno placement anywhere today
Free this periodnothing in this period, any cohort

These combine. "Not in this pod" and "Free today" is the substitute-hunting query, and today it is unaskable.

View all pods

A whole period-day. The board is the period, so two frames collapse into one.

Unplaced todayboard ≡ period here
Free todaynothing in any period today

The surface supplies its own set. A frame that can't be computed is never offered — the same availability idiom forgetUnavailableRosterGroupBys already uses.

Still open

What the board can't decide for you

M3 or M4 — settled: combine them

M6 takes M3's sentence, puts it at M4's position, and gives it M4's affordance: each section states its own cut in prose, and every noun in that sentence is the control that changes it. It costs 17 px per section rather than M4's 26, because a line of text is cheaper than a row of buttons — and it reads as an explanation rather than a control panel, which is the point. The remaining question is whether M7's Available / All tabs earn their 32 px on top, or whether a one-click preset that both populations share re-introduces exactly the shared vocabulary M6 just removed.

Does the backend count come before or after this ships?

Counting rows that match a filter should be dramatically cheaper than materialising them — no PII join, no enrichment, no placement index. If that count exists, L6 becomes ordinary and district stops being a special case in the UI at all: every scope has a number, the header never lies, and Auto-Assign keeps meaning "all". If it doesn't, every model above has to carry a permanent hole where a count should be. This is the one backend ask that changes the UI design rather than merely enabling it.

Is a slash enough to carry two populations in one chip?

M8 rule 3 (◍ Program⁄Group) is what lets a facet bar say what the tabs never could. Closed, it asks the reader to know that the first value is tutors and the second students — an ordering convention with nothing on screen to teach it. The alternatives are worse in other ways: two chips per field doubles the bar, and a single chip reading "mixed" hides the actual values. Worth testing on someone who hasn't seen it.

Does C4 break the panel's core gesture?

One-section-at-a-time reads beautifully and gives each population the full height. But building a pod means dragging a tutor and students into it, and C4 makes those two different views. C2 and C3 keep both reachable and cost chrome instead. This is the one axis a screenshot cannot settle — it needs someone actually building a pod.

Does pulling a district tutor onto a board enrol them in the program?

Out of scope for the UX question, but it decides whether the district result list is a picker (choose, then a mutation runs) or a pool (drag straight onto a pod like any other chip). The board renders it as a pool with dashed chips; if it's really a picker, a dedicated affordance is the honest shape and every inline model mildly misleads.

Cohort-scoped tutors ship dark

There is no tutor ↔ period edge to filter on, so the scope value must be offerable-but-absent rather than blocking. Reuse the existing availability idiom — an axis that would yield one section isn't offered, and a stored pick that stops being available is forgotten rather than re-armed. That trap is already solved in this package; don't solve it twice. It is also the same mechanism M8's dependent Rotation facet needs, which is an argument for building it once, properly.

Live board — every drawer above is rendered, and every menu is a real menu. Source: pod_roster_scoping/roster-scope-options.html