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 conflationThe 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.
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.
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.
| Scope | How it resolves | Cost |
|---|---|---|
| Group period cohort | client filter over loaded candidates.period | free |
| Program | client filter over candidates.program, capped at PROGRAM_ROSTER_MAX = 1000 | free |
| Group-scoped tutors | no data edge exists. Cohort is derived from section assignment; tutors aren't sectioned | backend |
| District | createDistrictRosterPageQuery — 50 rows/page, server-side search and role | a 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.
| Model | Shape | Measured at 340 px | Handles tutor ≠ student by | District 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 |
| Model | Behaviour | Buys | Costs |
|---|---|---|---|
| C1 Both always open | Today. 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 Collapsible | A 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-past | Section 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 time | A 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.
| Model | Behaviour | Comfortable to | What it costs |
|---|---|---|---|
| L1 Render all | Today. Every chip painted, one scroll. | ~60 | Nothing to build. Degrades silently — no signal that you're in a list you can't finish. |
| L2 Collapsed sections | Reuse #1583 grouping; sections render headed and folded, with counts. Open the one you want. | ~250 | Only 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 more | Paint the first 25, then Show 25 more. Search bypasses the cap. | ~150 | Cheap and honest about size. Repeated clicking; the cap interacts badly with sort (the "first 25" changes meaning). |
| L4 Virtualised | Whole 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. | ~2000 | Real 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-required | Above a threshold the list doesn't render. Empty state reads "Search to find a tutor", with recents pinned above it. | any | Kills 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-paged | Fetch 50, Load more. Renders “50 of 412” — which needs a count the server does not return today. | any | Loading 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. |
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.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.
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.
◍ 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.
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.
| Period | the cohort a person's sections place them in. Exists in the data today (periodGroups / memberCohortIds) — for students. Tutors have no such edge. |
| Rotation | dependent — 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 / Section | already 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.
Editing the persistent pods of one group. Nothing is about "today".
| Unplaced here | not in a pod on this board |
| In another group | placed in a different cohort of this period |
No "today" frame exists on this surface — offering one would be a lie.
One session, one day. The richest frame set, and where today's single word does the most damage.
| Not in this pod | the literal board |
| Free today | no placement anywhere today |
| Free this period | nothing in this period, any cohort |
These combine. "Not in this pod" and "Free today" is the substitute-hunting query, and today it is unaskable.
A whole period-day. The board is the period, so two frames collapse into one.
| Unplaced today | board ≡ period here |
| Free today | nothing 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.
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.
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.
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.
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.
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.
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.
pod_roster_scoping/roster-scope-options.html