Skip to content

feat(web): add a flock scope selector to the Dashboard, fixing the strip's scale fallback - #918

Merged
mforce merged 18 commits into
mainfrom
feat/916-production-scale
Sep 21, 2026
Merged

mforce merged 18 commits into
mainfrom
feat/916-production-scale

Conversation

@mforce

@mforce mforce commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

Amendment (2026-09-21) — bespoke picker replaced

The Lay-rate flock-scope picker described below as FlockPickerDialog (a bespoke, hand-rolled dialog) has been replaced by the shared NamedEntityPicker/FlockPicker inside the shared Dialog — the same nesting ExpensesPage.tsx already uses. docs/designs/822-mui-revamp.md's NamedEntityPicker↔Autocomplete row (pair 1, #826/#898) covers this screen: its slots.paper mechanism, already used for the Load-more footer, now also pins "All flocks" above the results via a new optional pinnedChoice prop, omitted by every other caller.

This is the record of a process failure as much as a feature: three defects fixed in review rounds below — the query-generation race on load-more, the missing initial focus on the search field, and the 37px tap target — all landed in mechanics NamedEntityPicker already had, reviewed and hardened under #898 and #512. That is the concrete cost "reused, never rebuilt" is pricing. The 44px fix now lives in the shared engine, benefiting eleven pre-existing production render sites across seven screens — nine FlockPicker (Daily entry, History, Feed ×2, Water ×2, Users, Expenses ×2) and two CustomerPicker (Sales ×2) — instead of one. (Correction: an earlier version of this note said "all five callers"; the real count is eleven, and this PR's own Dashboard usage makes it twelve.)

One divergence from the mockup is left as an open owner decision, not resolved in this PR: the mockup's own hand-rolled picker moves Home/End as list navigation; NamedEntityPicker deliberately keeps Home/End as native text-cursor movement (FR-031, serving every other render site above). This PR does not touch that contract.

Further amendment (2026-09-21) — reopen-after-select regression, fixed

An independent review found a P2: reopening the picker after picking a flock (or after picking "All flocks") marked nothing as the active scope — the shared Dialog unmounts its children on close (MUI's default keepMounted={false}), so every reopen is a fresh engine mount, and Dashboard never told it what was already committed. Fixed by threading scope through FlockPicker's existing controlledCommitted/controlledGeneration props, plus onClear (wired to the same reset "All flocks" performs, since seeding a committed entity also surfaces the engine's own footer Clear link, previously dead code). Checked empirically rather than assumed: this does pre-fill the reopened search field with the committed flock's name, matching how every other FlockPicker/CustomerPicker caller already behaves on reopen — not a regression, since the bespoke dialog this replaced never had a committed-flock concept to seed from — and does not narrow the results, since the seed touches only the display text, never the discovery filter. Four new regression tests in Dashboard.test.tsx, each mutation-verified.

Full detail and verification: see the PR comments at the 6c561bd and 1be88e0 rounds.


Closes #916

What changed

The bug (step 1, TDD). dayStrip() in web/src/lib/dashboard.ts scaled the 14-day strip's bars against the peak of complete days only. When no day in the window was complete — a farm where most houses never file — max fell straight to null and height() floored every bar at 2%, even though the tooltip showed a real figure. Fixed with a fallback: when no complete day exists but at least one day has a recorded figure, scale against the largest partial total instead, and label the caption "partial days only". The complete-day-only average is unchanged. A new scale field ("complete" | "partial" | "none") on DayStripData drives both the caption and (after a runtime-found defect, below) the accessible name.

Failing-test evidence before the fix (web/src/lib/dashboard.test.ts):

- Expected
+ Received

  {
    "average": null,
    "averagePct": null,
    "complete": 0,
-   "max": 500,
+   "max": null,
    "partial": 1,
-   "scale": "partial",
    "unrecorded": 1,
  }

The feature. The Dashboard's Lay rate card gains a flock scope, per the approved mockup in docs/designs/916-production-scale/SELECTION.md:

  • More than one accessible flock: an "All flocks" toggle beside the existing NamedEntityPicker/FlockPicker (MUI Autocomplete, feat(web): replace NamedEntityPicker's combobox with MUI Autocomplete #898) — All flocks stays reachable without opening the search at all, which is how this satisfies "All flocks stays available above the scrolling results" without adding a synthetic option inside the shared picker's own result list.
  • Exactly one accessible flock: its name as plain text, no picker at all.
  • The whole card follows the scope (bars, completeness, the complete-day average, both hen-day comparison periods); every other Dashboard panel is unaffected — the production-report fetch is now its own effect, separate from the other four panel fetches, so a scope change never re-triggers them.
  • The sole-accessible-flock case feeds the same getProductionReport(from, to, flockId) call a manual pick of that flock would produce — structural parity between "only one" and "selected single", confirmed both in a unit test and live (the restricted-Worker card and the Owner's card scoped to the same flock render identical figures: 98.8% / Avg 331.6 / Peak 342).

Server side. GET /api/v1/reports/production takes an optional flockId, scoped through ReportQueries.GetProductionAsync (eggs, grade totals, and the hen-day exposure walk narrow together — narrowing eggs without narrowing exposure would rate a healthy flock at a fraction of its real lay, the #780 defect in miniature). The endpoint checks IFlockRepository.GetByIdAsync first and 404s a flock the caller cannot see, reusing FlockEndpoints' own existence-check pattern.

Three defects found by end-to-end browser evidence, not by the unit suite, fixed in the same PR:

  1. The new "All flocks" button was 36px tall, under the repo's 44px tap-target floor — phone.spec.ts's standing guard went red against it. Bumped to 44px.
  2. The picker's onCommit never closed the search, unlike every other FlockPicker caller in the app (Daily entry, Feed, Water, Expenses, History). Fixed to close on commit.
  3. trendLabel's accessible sentence branched on max === null, which the scale fix already made true for both "no data" and "partial fallback" — so a screen reader was told "no peak or average" over a window whose visible caption and Peak figure showed a real number. Rewritten to branch on scale directly; the now-unreachable trendStripLabelNoComplete key is removed.

Docs. A GLOSSARY.md entry for the strip's scale fallback and for the flock scope, an in-app glossary entry in en/es/tl, and an addition to the existing Dashboard help text.

Suite totals

  • Web (npm run typecheck && npm test): typecheck clean, 3141/3141 passing (132 files), including the style guards (styles.bare-elements, styles.declared-tokens, styles.elevation, styles.harness-selectors, farmTheme.policy).
  • .NET: dotnet build Cluckwork.sln — 0 warnings, 0 errors. Cluckwork.Domain.Tests 491/491. Cluckwork.Application.Tests 532/532 (includes the module-ledger, adapter-reach, seam-surface and coupling-matrix guards — the new FlockManagement reach on ReportEndpoints.Production is declared there). Cluckwork.Api.IntegrationTests (Docker) 1854/1854, including three new tests: scoped-vs-farm-wide partition, a flock outside the caller's own flock scope (404), and a flock from another tenant or naming nothing at all (404).
  • Playwright, against a rebuilt isolated stack (never the shared cluckwork-sim): new spec tools/simulation/ui/specs/dashboard-flock-scope.spec.ts — 4 desktop + 1 @phone, 5/5 passing. Full quick smoke suite on that same stack: 62 passed, 1 pre-existing opt-in skip, 0 failures (the 44px guard failure above was found, fixed, and reverified green in this same pass — not left for a re-review round).

What's deliberately left to #914/#915

Per SELECTION.md's own "still outstanding" note: the 14-day window itself stays fixed (#914's range switcher integrates later — the flock-scope state is plain page state, not derived from anything that resets, so a range control can sit beside it without clearing the choice). Historical flock eligibility policy and any saved-preference persistence for the scope were flagged there as needing implementation review and are not addressed here.

Evidence

Before/after screenshots and the full capture set are attached in a follow-up comment, naming the head SHA.

@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 8b4c11ed-48af-44e1-bc15-1202e0f53404


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mforce

mforce commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Evidence at head SHA c5d60d4 (branch feat/916-production-scale), captured from an isolated cw916 Docker stack rebuilt from this exact checkout — never the shared cluckwork-sim.

Before (origin/main at aa685ad, default-farm, ~100 catalog flocks that never file): every bar collapses to the 2% floor regardless of the real totals behind them.

After (this branch, default-farm, All flocks — the default): the strip scales to the largest partial day, caption reads "partial days only", Peak is a real figure.

After, readme-farm (All flocks): a farm with complete days, ordinary complete-day scale, unaffected by the fallback.

After, the picker open with a search in progress.

After, a single flock selected — the picker closes on commit (one of the three runtime-found defects fixed in this PR) and the card rescopes.

After, the only-one-accessible-flock state: restrictedWorker() on the simulation fixture, whose GET /flocks read turns out to be scoped to exactly one flock by #613's structural filter (contradicting src/cast.ts's own — now stale — comment that this persona's reads are unrestricted). Its figures (98.8% / Avg 331.6 / Peak 342) are byte-identical to the single-flock-selected capture above for the same flock, which is the SELECTION.md parity requirement made visible.

All frames 1280×800 and 390×844, light and dark, device scale factor 1, viewport only.

Before, 1280 light — every bar at the 2% floor

Before, 1280 dark

Before, 390 light

Before, 390 dark

After, default-farm All flocks, 1280 light — partial-days-only scale

After, default-farm All flocks, 1280 dark

After, default-farm All flocks, 390 light

After, default-farm All flocks, 390 dark

After, readme-farm All flocks, 1280 light — complete-day scale

After, readme-farm All flocks, 1280 dark

After, readme-farm All flocks, 390 light

After, readme-farm All flocks, 390 dark

After, picker open with search, 1280 light

After, picker open with search, 1280 dark

After, picker open with search, 390 light

After, picker open with search, 390 dark

After, single flock selected, 1280 light

After, single flock selected, 1280 dark

After, single flock selected, 390 light

After, single flock selected, 390 dark

After, only one accessible flock, 1280 light

After, only one accessible flock, 1280 dark

After, only one accessible flock, 390 light

After, only one accessible flock, 390 dark

mforce added a commit that referenced this pull request Sep 20, 2026
…dings

Fidelity round on PR #918 (owner directive: the mockup is the spec, #901).
The Lay rate card's flock scope now matches
docs/designs/916-production-scale/production-flock-selector-v2.html in DOM
order and control shape rather than approximating it:

- One full-width selector (eyebrow "Flock" + value + chevron) replaces the
  separate All-flocks chip beside an MUI Autocomplete. It opens a picker
  dialog built to the mockup's own shape -- a 44px close button, a labelled
  search input filtering the already-loaded flock list client-side, and an
  "All flocks" choice pinned ABOVE the scrolling result list, never a row
  inside it.
- A context caption ("{N} accessible flocks / the flock's name * range")
  sits under the selector.
- The scale caption now reads "Eggs per day * complete-day scale" /
  "* partial days only" / "* no recorded figures", and Peak/Avg always
  render a sentence ("Peak --", "No complete-day average") instead of
  hiding when null.
- DayStrip gains the mockup's three-item legend (Complete/Partial/No entry).
- The hen-day KPI moves from the top of the card to the bottom, after the
  strip, on both desktop and phone.

Also folds in three findings from Codex's review of c5d60d4:
- The picker's only reset path (choosing "All flocks" inside the dialog)
  sets scope and the displayed value from the SAME state now, so there is
  no second "committed" mirror left to desync (finding 1).
- The page-level "everything failed" decision now waits for the production
  report's own first outcome too, not just the four panel reads, so a farm
  where only those four fail no longer hides an already-loaded Lay rate
  card behind the full-page error; `loading` itself still resolves as soon
  as the four settle, so a slow production fetch never blocks the page
  (finding 2).
- A new test drives an explicit stale-response race (older scope resolves
  after a newer one) and asserts the newer figures survive; verified by
  deleting the trend effect's `cancelled` guard and confirming the test
  goes red, then reverting (finding 3).

Web suite: 3148/3148 passing, typecheck clean. Playwright, rebuilt isolated
stack: the flock-scope spec's 6 tests (5 desktop + 1 @phone) and the full
63-test quick smoke suite (1 pre-existing skip) all green.
@mforce

mforce commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Fidelity round + Codex review findings, both done at head f6169b8924bfff465d3a8ed6ba8d9e8398dd26b8.

Mockup fidelity

Rebuilt the Lay rate card to match docs/designs/916-production-scale/production-flock-selector-v2.html in DOM order and control shape, not just in spirit:

  1. .production-head — unchanged (title left, "Last 14 days" right).
  2. .scope — one full-width selector button (eyebrow "FLOCK" + value + chevron), margin: 18px 0 8px, replacing the separate "All flocks" chip beside an MUI Autocomplete. Opens a picker dialog: header with title and a 44px close button, a "Search accessible flocks" label + input, an "All flocks" choice pinned ABOVE the scrolling result list (never a row inside it), the results, and a no-results state.
  3. .context — "{N} accessible flocks · {range}" under the selector.
  4. .scale — "Eggs per day · complete-day scale" / "· partial days only" / "· no recorded figures"; Peak and Avg always render a sentence now ("Peak —", "No complete-day average") instead of hiding when null.
    5/6. Dock, strip, avgline, rule — unchanged. Legend added (Complete solid / Partial hatched / No entry outlined).
  5. Hen-day KPI moved from the top of the card to the bottom, after the strip — same order on phone.

Codex review of c5d60d4 — all three folded in

Finding 1 (P2, picker Clear with no handler). The new single-selector design has exactly one reset path — choosing "All flocks" inside the dialog — and it sets scope and the displayed value from the same state in the same handler. There's no second "committed" mirror left to fall out of sync with it. Test: the selector's accessible name reflects the scope after a pick and the return-to-All-flocks half of scopes the whole card to a picked flock....

Finding 2 (P2, "everything failed" excludes production). evaluateTotalFailure() now waits for both the four-panel effect AND the production report's own first outcome before deciding whether to show the full-page error; loading itself still resolves as soon as the four panel reads settle, so a slow production fetch never blocks the page render. Test: the OTHER four failing does not hide a Lay rate card the production report loaded successfully (the missing direction) plus the pre-existing every issued fetch failed → the page-level message (the direction that already worked).

Finding 3 (P3, no test for the stale-response race). New test keeps the newer scope's figures when an older scope's request resolves later, driven with manually-controlled promises so the older ("all flocks") request can be resolved after the newer ("Flock f2") one. Mutation result, as asked: deleting if (cancelled) return; from the trend effect turned this test red —

expect(screen.getByText("87.4%")).toBeInTheDocument();
Unable to find an element with the text: 87.4%

— confirmed locally, then reverted; the suite is green again with the guard back in place.

Verification

  • Web: npm run typecheck && npm test -- --maxWorkers=2 — clean, 3148/3148 passing (132 files).
  • .NET: unaffected by this round (no backend changes since c5d60d4); already green from the prior round.
  • Playwright, rebuilt isolated cw916 stack (never the shared cluckwork-sim): the rewritten tools/simulation/ui/specs/dashboard-flock-scope.spec.ts — 5 desktop + 1 @phone, 6/6 passing — and the full 64-test quick smoke suite, 63 passed / 1 pre-existing opt-in skip, 0 failures.
  • Deslop pass run and committed in the same commit as this round's code (no separate chore commit this time — the two findings were a removed no-op eslint-disable comment and nothing else).

Screenshots (all 20 "after" frames recaptured against this round's build; 1280×800 and 390×844, light and dark, device scale factor 1, viewport only) are attached in the next comment.

The isolated cw916 stack, its image, and .tmp have been torn down. Let me know if you want anything else checked before the next round.

@mforce

mforce commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

All 20 frames, recaptured against head f6169b8924bfff465d3a8ed6ba8d9e8398dd26b8 on the rebuilt isolated cw916 stack. 1280×800 and 390×844, light and dark, device scale factor 1, viewport only.

default-farm, All flocks — single selector, context caption, "partial days only" scale, legend, KPI at the bottom.

readme-farm, All flocks — complete-day scale.

Picker open — "Choose flock" header with 44px close, search filtered to "Sim House", "All flocks" pinned above the two matching results.

Single flock selected — "Sim House A" scoped, complete-day scale, Complete-day avg + Peak.

Only one accessible flock (restrictedWorker()) — plain text "Sim House A" under the "FLOCK" eyebrow, no button, no chevron. Same figures as the single-selected frames above — the SELECTION.md parity requirement, visible.

All flocks, 1280 light

All flocks, 1280 dark

All flocks, 390 light

All flocks, 390 dark

readme-farm All flocks, 1280 light

readme-farm All flocks, 1280 dark

readme-farm All flocks, 390 light

readme-farm All flocks, 390 dark

Picker open, 1280 light

Picker open, 1280 dark

Picker open, 390 light

Picker open, 390 dark

Single flock selected, 1280 light

Single flock selected, 1280 dark

Single flock selected, 390 light

Single flock selected, 390 dark

Only one accessible flock, 1280 light

Only one accessible flock, 1280 dark

Only one accessible flock, 390 light

Only one accessible flock, 390 dark

mforce added a commit that referenced this pull request Sep 20, 2026
… generation, add picker keyboard nav and a flock-list-unavailable state (#918)

Codex review, round 3 (c5d60d4..f6169b8): the dialog's search only
filtered the first 500 already-loaded flocks, so a flock past that page
was unreachable on a large farm — search is now server-paged and
debounced (50 rows/250ms), matching the shared picker engine. The
"everything failed" gate kept its first-ever panel/trend outcome
forever, so an earlier failure could outlive a later refresh that
succeeded (a role change flipping canSeeSales) — both outcomes are now
keyed by a load generation counter. The results list gained
Arrow/Home/End/Enter roving focus, and a failed flock-list read now
shows the existing panel-error pattern with Retry instead of reading as
an empty farm.
@mforce

mforce commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Round 3 pushed at bbc134e (f6169b8..bbc134e).

Codex astra's four findings against c5d60d4..f6169b8:

  1. P2, Dashboard.tsx:230 (picker search capped at 500 loaded flocks). The dialog's discovery is now server-paged and debounced (50 rows, 250ms), the same shape the shared NamedEntityPicker engine uses — listFlocks({ search, eligibility: "active-and-depleted", limit: 50, offset }), with a "Load more" button paging past the first 50. Test: reaches a flock past the first page by searching the server, not by filtering an already-loaded list — an unfiltered fixture that never contains the 501st-equivalent flock, only reachable once the dialog issues a real server call carrying that search term.
  2. P2, Dashboard.tsx:178 (stale first-ever failure outlives a later successful refresh). panelsOutcomeRef/trendOutcomeRef are now keyed by a loadGenRef counter bumped once per genuine new load ([today, canSeeSales]), and a new generation clears any previous error optimistically. Test: clears a page-level error once a later refresh succeeds — the rollover case — fails everything under one role, then flips the role (canSeeSales false → true) on the SAME mounted Dashboard and lets a fresh load succeed; the page error clears.
  3. P3, Dashboard.tsx:529 (no Arrow/Home/End navigation). The results container now supports Arrow/Home/End/Enter roving focus, with "All flocks" reachable first (pinned above the scrolling list, first in DOM order). Test: moves focus through the picker's choices with Arrow/Home/End, All flocks reachable first.
  4. P3, Dashboard.tsx:228 (failed flock-list read reads as "0 accessible flocks"). A failed listFlocks read now shows the existing panel-error pattern (Retry re-issues only that read) instead of an empty-looking selector. Test: shows the flock list as unavailable, not an empty farm, when the flock read fails; Retry recovers it.

Declining one finding, with evidence — not implemented: "a production request that never settles leaves the card loading indefinitely; no deadline." web/src/api/client.ts's raw() (line 133) is the one function that ever calls fetch() for a JSON request, at line 151: const res = await fetch(\${BASE}${path}`, { ...init, headers });— noAbortSignal, no timeout parameter, no deadline mechanism anywhere in it. apiFetch(the functiongetProductionReport, listFlocks, listDailyEntries, getStockandlistOrdersall resolve to throughapiGet, line 782) calls rawthree times — lines 913, 925 and 936 — none of them pass a deadline either. Every read this Dashboard issues, not only the production report, shares this exact path, so a timeout added only to the one call this finding names would be inconsistent with the other five. The one place this file DOES carry a timeout,withAuthCookieLock(line 542, optionaltimeoutMs`), is a narrow special case for a specific cross-tab auth-refresh lock, not a general precedent. Carrying this to the owner's issue batch as a repo-wide "add a fetch deadline" decision rather than a one-line special case here.

Verification: npm run typecheck and the full npm test -- --maxWorkers=2 (3152 tests) both green, the quick Playwright suite (6 tests, including the phone project) green against a cw916 stack rebuilt at this head, and a local mutation check on the rollover fix (temporarily reverting the generation-keyed guard back to a first-write-only ref turns the rollover test red, confirmed then reverted).

Screenshots below: the picker-open state at 1280/390, light/dark, plus one search-result frame — the only surfaces round 3 touched.

Picker open, 1280 light

Picker open, 1280 dark

Picker open, 390 light

Picker open, 390 dark

Search result, 1280 light

mforce added a commit that referenced this pull request Sep 20, 2026
…eneration, and rerun production on a role change (#918)

Codex review, round 4 (f6169b8..HEAD): the picker's own state (search,
paging, keyboard nav) moves into a new FlockPickerDialog component with
its own tests, so Dashboard.tsx only holds whether the dialog is open
and the scope a pick produced. Within it: extension requests and the
debounced search are now tied to a query generation, dropping a stale
or closed-dialog response instead of corrupting the results/cursor; a
failed discovery request shows its own unavailable state with Retry
instead of reading as a real empty result; and keyboard roving focus
skips a disabled Load more button.

In Dashboard.tsx: the production-report effect now also reruns on a
role change alone (canSeeSales), which the panels effect's own
generation bump already did — without it, a role change with the
flock set unchanged left no trend outcome recorded for the new
generation, silently swallowing a genuine total failure. Retry on a
failed flock-list read no longer clears the unavailable state before
the retried read settles.
@mforce

mforce commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Round 4 pushed at bbaf1ef (bbc134e..bbaf1ef).

Given how much state the picker dialog was carrying (query generation, load generation, paging cursor, dialog focus), the dialog now lives in its own component — web/src/components/FlockPickerDialog.tsx — with its own test file, so Dashboard.tsx only holds whether the dialog is open and the scope a pick produced.

Codex astra's six findings against f6169b8..bbc134e:

  1. P2, Dashboard.tsx:322 (load-more had no query-generation guard). Every extension request and the debounced search itself are now tied to a queryGenRef counter bumped on every query change (new search text, dialog open/close, or Retry); a response is dropped if the generation it was dispatched under is no longer current, and results/cursor/hasMore are cleared in the same tick a query changes, not only once the fresh page lands. Test: rejects an older query's response when it resolves after a newer query already answered (mutation-verified locally — dropping the generation guard turns it red, the stale results overwrite the fresh ones — confirmed then reverted).
  2. P2, Dashboard.tsx:288 (role change advanced loadGenRef without rerunning production). The trend effect's dependency array now includes canSeeSales, matching the panels effect's own trigger. Test: reruns production on a role change alone, so a genuine total failure in the new generation is still reported — holds three flocks across both generations so soleFlockId stays null (isolating the role-only trigger from the confound the round-3 rollover test had). Mutation-verified: dropping canSeeSales from the dependency array turns it red (the page-level error never appears), confirmed then reverted.
  3. P2, Dashboard.tsx:310 (failed discovery read as "No matching flocks"). The dialog now has its own discoveryFailed state, rendering an Alert with Retry inside the results area instead. Test: shows a discovery-unavailable state with Retry, not a false empty result, when search fails (FlockPickerDialog.test.tsx).
  4. P3, Dashboard.tsx:215 (Retry flashed "0 accessible flocks"). flocksFailed is now left alone until the retried read settles; the Retry button itself shows the shared "loading" label and disables while in flight. Test: shows the flock list as unavailable, not an empty farm, when the flock read fails; Retry recovers it (strengthened with a deferred response to assert the mid-flight state). Mutation-verified: clearing flocksFailed up front (the old behavior) turns it red, confirmed then reverted.
  5. P3, Dashboard.tsx:142 (disabled Load more caught in keyboard nav). The roving-focus handler now filters to enabled buttons only. Test: keyboard navigation skips a disabled Load more, landing on the last enabled result (FlockPickerDialog.test.tsx).
  6. P3, Dashboard.test.tsx:770 (no debounce-timing or overlapping-generation coverage). Both now covered in FlockPickerDialog.test.tsx: waits out the debounce before issuing a search request (fake timers, asserting no request at 249ms and exactly one at 250ms) and rejects an older query's response when it resolves after a newer query already answered (the same test that verifies finding 1).

The two round-3 picker-mechanics tests that duplicated what now lives in FlockPickerDialog.test.tsx (reaches a flock past the first page... and moves focus through the picker's choices...) were removed from Dashboard.test.tsx; wiring is still covered there by the existing scopes the whole card to a picked flock... test.

Verification: npm run typecheck and the full npm test -- --maxWorkers=2 (3157 tests, 133 files) both green, the quick Playwright suite (6 tests) green against a cw916 stack rebuilt at this head. No screenshot recapture — the dialog's markup and styling moved verbatim, nothing rendered differently.

mforce added a commit that referenced this pull request Sep 20, 2026
…re against a stale query or a closed dialog (#918)

Codex review, round 5 (bbc134e..bbaf1ef): the round-4 extraction had
narrowed the keyboard test down to the disabled-Load-more boundary
case alone, dropping the Arrow/Home sweep, the search-to-results
ArrowDown handoff, and Escape's return-focus-to-trigger behavior — all
restored in one test against a real trigger button. The generation-race
test covered a replacement search only; a Load More extension left
pending across a query change, or across the dialog closing and
reopening, was unguarded. Both gaps are test-only: the product code
already had the line-104 generation guard from round 4.
@mforce

mforce commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Round 5 (last round) pushed at 31e4cce (bbaf1ef..31e4cce). Test-only, per the review: no defects in the component or its Dashboard wiring.

  1. P3, FlockPickerDialog.test.tsx:144 (load-more staleness under-covered). The existing race test only covered a replacement search; a loadMore extension left pending across a query change, or across the dialog closing and reopening, was unguarded by any test even though the product code (component line 104) already handled it. Added two tests: drops a pending Load more response when the search query changes before it resolves, and the new query's own cursor is never corrupted and drops a pending Load more response when the dialog closes before it resolves, and reopening starts with a clean cursor. Mutation-verified: deleting the generation guard at line 104 turns both red (the stale rows land, appended onto the fresh query's own results), confirmed locally then reverted.
  2. P3, FlockPickerDialog.test.tsx:76 (keyboard coverage narrowed by the round-4 extraction). Restored in one test, moves focus through the picker with Arrow/Home/End from the search box, skips a disabled Load more, and returns focus to the trigger on Escape: the search-to-results ArrowDown handoff, the ordinary Arrow/Home/End sweep, the disabled-Load-more exclusion, and Escape returning focus to a real trigger button (MUI's own restore-focus behavior, proved end to end rather than assumed). Mutation-verified: removing onKeyDown={onBodyKeyDown} from the results container turns it red (focus never leaves the search box), confirmed locally then reverted.

Verification: npm run typecheck and the full npm test -- --maxWorkers=2 (3159 tests, 133 files) both green. No stack, no captures — test-only round.

@mforce

mforce commented Sep 20, 2026

Copy link
Copy Markdown
Owner Author

Review loop — closed at 31e4cce

Reviewer: Codex (gpt-6-astra via Paseo) per round; a coordinator rule-by-rule comparison of the frames against the approved production-flock-selector-v2.html after round 1. CodeRabbit is not triggered on this repo.

Round Head What it fixed Product defects
1 c5d60d4 TDD scale fix, server-side flock scoping, selector, three-locale Help; initial review 2 (Clear left the card on the old flock; page-level error hid a loaded Lay Rate card)
2 f6169b8 Card rebuilt to the mockup's DOM order (single selector → context caption → scale figures → strip → legend → hen-day last); picker dialog with pinned "All flocks"; stale-response race test 4 (search capped at the first 500 loaded flocks; stale failure across the farm-day rollover; no keyboard navigation; failed flock list shown as an empty farm)
3 bbc134e Server-paged debounced search; failure outcomes keyed by load generation; roving focus; unavailable state with Retry 3 (old load-more page appended to newer results; role change advanced the generation without rerunning production; failed search shown as "No matching flocks")
4 bbaf1ef Picker extracted to FlockPickerDialog with its own tests; query-generation guard on all three request kinds and on close; Retry keeps the loading state; keyboard skips disabled buttons; fake-timer debounce and out-of-order generation tests 0
5 31e4cce Test-only: pending load-more across query change and close/reopen; Arrow/Home, search-to-results and Escape-returns-focus assertions; all mutation-proven 0

Declined with evidence (round 3): a production request that never settles leaves the card loading; web/src/api/client.ts raw() (line 151) applies no deadline to any read, so a timeout only here would be inconsistent. Carried to the owner's issue batch.

Verification at 31e4cce: typecheck clean, Vitest 3,159 across 133 files; at bbaf1ef the quick Playwright suite plus the new 6-test spec were green on a rebuilt isolated stack; .NET at c5d60d4 (server code unchanged since): Domain 491, Application 532 (ledger, adapter-reach and coupling-matrix guards), Integration 1,854 under Docker. CI green at every head through bbaf1ef; 31e4cce (tests only) is being watched. Frames: 24 at c5d60d4, 20 recaptured at f6169b8, picker/search frames at bbc134e.

Loop stopped deliberately after round 5: rounds 4 and 5 confirmed no product defect. Left to #914/#915 per SELECTION.md: range control (must not reset the flock choice), historical flock eligibility and saved-preference persistence.

@mforce

mforce commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Deslop pass — f618a82

Ran /pstack:deslop over the branch diff. Comments and one duplicated
derivation only; no behaviour change
, so the head moving after the review
loop was stopped does not reopen it.

What changed

  • Trimmed review-round narration out of the production code. The house style
    here does cite a finding that shipped a defect (#883 round 2, finding 3 and
    friends are all over main), and every such citation in the tests is
    kept — a regression test's provenance is why it exists. What came out is the
    other thing: paragraphs re-litigating what an earlier draft of this same
    unmerged branch
    did wrong, which a future reader cannot act on because that
    code never existed on main. The invariants those paragraphs surrounded are
    all kept, shorter.
  • Dashboard.tsx was 174/645 comment lines (27%) against main's
    50/402 (12%); it is now 115/586 (20%), on a file that grew by a
    whole subsystem.
  • Folded the duplicated soleFlock / soleFlockId expressions into one
    derivation (soleFlockId = soleFlock?.id ?? null), which also removed a
    second copy of the comment explaining it.
  • Fixed one comment that had gone stale: Dashboard.tsx's tp namespace note
    still described the picker's Retry/Load-more strings after the picker moved
    out to FlockPickerDialog.tsx; Dashboard now uses that namespace only for
    the flock-list Retry/loading labels, and the comment says so.

Not touched: the C# diff (its comment density matches the surrounding
file and every line states a live invariant), and all four test files.

Verification

  • npm run typecheck — clean.
  • npm test -- --run — 133 files, 3159 tests, all passing.

No re-review triggered: the loop was stopped deliberately and this commit
adds no product surface to review.

@mforce
mforce force-pushed the feat/916-production-scale branch from f618a82 to 26ebcc5 Compare September 21, 2026 02:00
mforce added a commit that referenced this pull request Sep 21, 2026
…dings

Fidelity round on PR #918 (owner directive: the mockup is the spec, #901).
The Lay rate card's flock scope now matches
docs/designs/916-production-scale/production-flock-selector-v2.html in DOM
order and control shape rather than approximating it:

- One full-width selector (eyebrow "Flock" + value + chevron) replaces the
  separate All-flocks chip beside an MUI Autocomplete. It opens a picker
  dialog built to the mockup's own shape -- a 44px close button, a labelled
  search input filtering the already-loaded flock list client-side, and an
  "All flocks" choice pinned ABOVE the scrolling result list, never a row
  inside it.
- A context caption ("{N} accessible flocks / the flock's name * range")
  sits under the selector.
- The scale caption now reads "Eggs per day * complete-day scale" /
  "* partial days only" / "* no recorded figures", and Peak/Avg always
  render a sentence ("Peak --", "No complete-day average") instead of
  hiding when null.
- DayStrip gains the mockup's three-item legend (Complete/Partial/No entry).
- The hen-day KPI moves from the top of the card to the bottom, after the
  strip, on both desktop and phone.

Also folds in three findings from Codex's review of c5d60d4:
- The picker's only reset path (choosing "All flocks" inside the dialog)
  sets scope and the displayed value from the SAME state now, so there is
  no second "committed" mirror left to desync (finding 1).
- The page-level "everything failed" decision now waits for the production
  report's own first outcome too, not just the four panel reads, so a farm
  where only those four fail no longer hides an already-loaded Lay rate
  card behind the full-page error; `loading` itself still resolves as soon
  as the four settle, so a slow production fetch never blocks the page
  (finding 2).
- A new test drives an explicit stale-response race (older scope resolves
  after a newer one) and asserts the newer figures survive; verified by
  deleting the trend effect's `cancelled` guard and confirming the test
  goes red, then reverting (finding 3).

Web suite: 3148/3148 passing, typecheck clean. Playwright, rebuilt isolated
stack: the flock-scope spec's 6 tests (5 desktop + 1 @phone) and the full
63-test quick smoke suite (1 pre-existing skip) all green.
mforce added a commit that referenced this pull request Sep 21, 2026
… generation, add picker keyboard nav and a flock-list-unavailable state (#918)

Codex review, round 3 (c5d60d4..f6169b8): the dialog's search only
filtered the first 500 already-loaded flocks, so a flock past that page
was unreachable on a large farm — search is now server-paged and
debounced (50 rows/250ms), matching the shared picker engine. The
"everything failed" gate kept its first-ever panel/trend outcome
forever, so an earlier failure could outlive a later refresh that
succeeded (a role change flipping canSeeSales) — both outcomes are now
keyed by a load generation counter. The results list gained
Arrow/Home/End/Enter roving focus, and a failed flock-list read now
shows the existing panel-error pattern with Retry instead of reading as
an empty farm.
mforce added a commit that referenced this pull request Sep 21, 2026
…eneration, and rerun production on a role change (#918)

Codex review, round 4 (f6169b8..HEAD): the picker's own state (search,
paging, keyboard nav) moves into a new FlockPickerDialog component with
its own tests, so Dashboard.tsx only holds whether the dialog is open
and the scope a pick produced. Within it: extension requests and the
debounced search are now tied to a query generation, dropping a stale
or closed-dialog response instead of corrupting the results/cursor; a
failed discovery request shows its own unavailable state with Retry
instead of reading as a real empty result; and keyboard roving focus
skips a disabled Load more button.

In Dashboard.tsx: the production-report effect now also reruns on a
role change alone (canSeeSales), which the panels effect's own
generation bump already did — without it, a role change with the
flock set unchanged left no trend outcome recorded for the new
generation, silently swallowing a genuine total failure. Retry on a
failed flock-list read no longer clears the unavailable state before
the retried read settles.
mforce added a commit that referenced this pull request Sep 21, 2026
…re against a stale query or a closed dialog (#918)

Codex review, round 5 (bbc134e..bbaf1ef): the round-4 extraction had
narrowed the keyboard test down to the disabled-Load-more boundary
case alone, dropping the Arrow/Home sweep, the search-to-results
ArrowDown handoff, and Escape's return-focus-to-trigger behavior — all
restored in one test against a real trigger button. The generation-race
test covered a replacement search only; a Load More extension left
pending across a query change, or across the dialog closing and
reopening, was unguarded. Both gaps are test-only: the product code
already had the line-104 generation guard from round 4.
@mforce
mforce force-pushed the feat/916-production-scale branch from 26ebcc5 to 8c6f95f Compare September 21, 2026 02:56
mforce added a commit that referenced this pull request Sep 21, 2026
…dings

Fidelity round on PR #918 (owner directive: the mockup is the spec, #901).
The Lay rate card's flock scope now matches
docs/designs/916-production-scale/production-flock-selector-v2.html in DOM
order and control shape rather than approximating it:

- One full-width selector (eyebrow "Flock" + value + chevron) replaces the
  separate All-flocks chip beside an MUI Autocomplete. It opens a picker
  dialog built to the mockup's own shape -- a 44px close button, a labelled
  search input filtering the already-loaded flock list client-side, and an
  "All flocks" choice pinned ABOVE the scrolling result list, never a row
  inside it.
- A context caption ("{N} accessible flocks / the flock's name * range")
  sits under the selector.
- The scale caption now reads "Eggs per day * complete-day scale" /
  "* partial days only" / "* no recorded figures", and Peak/Avg always
  render a sentence ("Peak --", "No complete-day average") instead of
  hiding when null.
- DayStrip gains the mockup's three-item legend (Complete/Partial/No entry).
- The hen-day KPI moves from the top of the card to the bottom, after the
  strip, on both desktop and phone.

Also folds in three findings from Codex's review of c5d60d4:
- The picker's only reset path (choosing "All flocks" inside the dialog)
  sets scope and the displayed value from the SAME state now, so there is
  no second "committed" mirror left to desync (finding 1).
- The page-level "everything failed" decision now waits for the production
  report's own first outcome too, not just the four panel reads, so a farm
  where only those four fail no longer hides an already-loaded Lay rate
  card behind the full-page error; `loading` itself still resolves as soon
  as the four settle, so a slow production fetch never blocks the page
  (finding 2).
- A new test drives an explicit stale-response race (older scope resolves
  after a newer one) and asserts the newer figures survive; verified by
  deleting the trend effect's `cancelled` guard and confirming the test
  goes red, then reverting (finding 3).

Web suite: 3148/3148 passing, typecheck clean. Playwright, rebuilt isolated
stack: the flock-scope spec's 6 tests (5 desktop + 1 @phone) and the full
63-test quick smoke suite (1 pre-existing skip) all green.
mforce added a commit that referenced this pull request Sep 21, 2026
… generation, add picker keyboard nav and a flock-list-unavailable state (#918)

Codex review, round 3 (c5d60d4..f6169b8): the dialog's search only
filtered the first 500 already-loaded flocks, so a flock past that page
was unreachable on a large farm — search is now server-paged and
debounced (50 rows/250ms), matching the shared picker engine. The
"everything failed" gate kept its first-ever panel/trend outcome
forever, so an earlier failure could outlive a later refresh that
succeeded (a role change flipping canSeeSales) — both outcomes are now
keyed by a load generation counter. The results list gained
Arrow/Home/End/Enter roving focus, and a failed flock-list read now
shows the existing panel-error pattern with Retry instead of reading as
an empty farm.
mforce added a commit that referenced this pull request Sep 21, 2026
…eneration, and rerun production on a role change (#918)

Codex review, round 4 (f6169b8..HEAD): the picker's own state (search,
paging, keyboard nav) moves into a new FlockPickerDialog component with
its own tests, so Dashboard.tsx only holds whether the dialog is open
and the scope a pick produced. Within it: extension requests and the
debounced search are now tied to a query generation, dropping a stale
or closed-dialog response instead of corrupting the results/cursor; a
failed discovery request shows its own unavailable state with Retry
instead of reading as a real empty result; and keyboard roving focus
skips a disabled Load more button.

In Dashboard.tsx: the production-report effect now also reruns on a
role change alone (canSeeSales), which the panels effect's own
generation bump already did — without it, a role change with the
flock set unchanged left no trend outcome recorded for the new
generation, silently swallowing a genuine total failure. Retry on a
failed flock-list read no longer clears the unavailable state before
the retried read settles.
mforce added a commit that referenced this pull request Sep 21, 2026
…re against a stale query or a closed dialog (#918)

Codex review, round 5 (bbc134e..bbaf1ef): the round-4 extraction had
narrowed the keyboard test down to the disabled-Load-more boundary
case alone, dropping the Arrow/Home sweep, the search-to-results
ArrowDown handoff, and Escape's return-focus-to-trigger behavior — all
restored in one test against a real trigger button. The generation-race
test covered a replacement search only; a Load More extension left
pending across a query change, or across the dialog closing and
reopening, was unguarded. Both gaps are test-only: the product code
already had the line-104 generation guard from round 4.
mforce added a commit that referenced this pull request Sep 21, 2026
…918)

Deslop follow-up: measuring comment density against main caught the
overall increase but missed block LENGTH — several branch-added blocks
still ran 5-9 lines. Sixteen such blocks across Dashboard.tsx,
FlockPickerDialog.tsx, the Playwright spec and lib/dashboard.ts are now
at or under 4 lines, with the load-bearing invariants (the total-failure
verdict spanning both effects and keyed to loadGenRef; why the picker's
results/cursor clear in the same tick as a query change) kept intact.
lib/dashboard.ts's pre-existing half (from main) is untouched; only the
branch-added tail was trimmed.
mforce added a commit that referenced this pull request Sep 21, 2026
…a stale retry verdict, cancel superseded reports, and be honest about a truncated flock count (#918)

First proper review seat on #918, found three P2s and two P3s:

- P2-1: the Morning collection panel's "Yesterday by close" caption
  derived from the SAME scoped trend the Lay rate card reads, so
  picking a flock changed a different panel's figure. It is now its
  own always farm-wide, single-day fetch, decoupled from scope.
- P2-2: a successful flock-list Retry never updated the page-level
  failure verdict, so a stale "all four panels failed" record could
  survive a recovered flock list and hide the whole dashboard once the
  auto-triggered sole-flock production read then failed. The
  generation-ref machinery is replaced with two outcome states
  (pending/someOk/allFailed, pending/success/failure) that Retry now
  updates directly.
- P2-3: a superseded production request was only ignored, never
  aborted, so it could sit in flight holding a report-concurrency
  permit. The trend effect now uses an AbortController per dispatch,
  threaded through apiGet/getProductionReport.
- P3-4: the picker dialog focused its close button on open instead of
  the search box, and the search box was under the 44px touch-target
  floor. Fixed with autoFocus and a min-height override on the input
  wrapper.
- P3-5: the accessible-flock count presented as exact past
  listFlocks's 500-row cap. It now reads "500+" once truncated.

Every fix carries a test that fails on the pre-fix code and a local
mutation check confirming it; see the PR reply for the exact evidence.
mforce added a commit that referenced this pull request Sep 21, 2026
… CI flake in a picker test the branch didn't introduce (#918)

A dashboard load fired three /reports/production requests (two
adjacent trend windows plus the new yesterday-close fetch), three of
the account's four shared report-concurrency permits
(RateLimitingOptions.ReportsConcurrency: PermitLimit 4, QueueLimit 0,
no queue — anything past the cap 429s rather than waiting). Two
workers on the same farm opening the app together could exceed it.

The two adjacent trend windows (current week, previous week) are now
one daysBefore(today,14)..daysBefore(today,1) request, split
client-side by the new splitProductionReport. Every report-level
total is a plain sum over days[], computed the identical way
server-side (ReportQueries.GetProductionAsync), so summing a subset of
an already-fetched period reproduces exactly what a second request for
that subset would have returned — verified both by a fast unit test
against a hand-built fixture and, more importantly, against the real
simulation server's own combined-vs-separate responses. A test pins
the request count at two per load so the ceiling cannot creep back.

Also fixes an unrelated CI flake in NamedEntityPicker.test.tsx exposed
by this branch's CI run: a raw KeyboardEvent dispatch plus an empty
act() raced MUI Autocomplete's controlled-state commit. Converted to
userEvent + waitFor; two other raw-dispatch tests in that file were
checked and are not the same race (one asserts a stable negative on a
disabled control, the other needs the raw event's defaultPrevented,
which userEvent does not expose), so they were left alone.
@mforce

mforce commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Two things folded into this push at 6205244 (04275ed..6205244).

Trend-window fold, per the concurrency review. A dashboard load fired three /reports/production requests (the two adjacent trend windows plus the yesterday-close fetch from the last round) against RateLimitingOptions.ReportsConcurrency (src/Cluckwork.Api/RateLimiting/RateLimitingOptions.cs:27: PermitLimit = 4, QueueLimit = 0), an account-wide, unqueued cap. Two workers on the same farm opening the app together is six requests against four permits, and anything past the cap 429s immediately rather than waiting.

The two adjacent trend windows (current week, previous week) are now one daysBefore(today,14)..daysBefore(today,1) request, split client-side by a new splitProductionReport (web/src/lib/productionReportSplit.ts). This is checked, not assumed: every report-level total is a plain sum over days[], computed identically server-side in ReportQueries.GetProductionAsync (src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs:311-326) — totalEggs = sum(days.totalEggs), periodHenDayPct = round(sum(days.ratedEggs) * 100 / sum(days.recordedHenDays), 1). Summing a subset of an already-fetched, contiguous period reproduces exactly what a second request for that subset would have returned. Verified two ways: a fast unit test against a hand-built fixture (web/src/lib/productionReportSplit.test.ts), and the test you actually asked for — splitting one 14-day report client-side equals two separate 7-day requests in dashboard-flock-scope.spec.ts, which fetches the combined window AND the two separate windows from the real running simulation server and asserts the client-side split equals the server's own separate answers. gradeTotals is the one field that can't be reconstructed this way (a per-grade breakdown the per-day rows don't carry); the Dashboard's trend consumers never read it, so each half's is left empty rather than guessed.

asks the production report for exactly one 14-day window plus one single-day yesterday fetch — never more pins the per-load request count at two, so the ceiling can't creep back to three.

CI flake in NamedEntityPicker.test.tsx, unrelated to this branch's own changes. Run 35563849681 failed commits the active option on Enter (expected '105' to be 'Flock 105') and passed on identical code locally. Cause: the test drove keyboard interaction with a raw KeyboardEvent dispatch plus an empty act(), then immediately read input.value. MUI Autocomplete (from #898) commits the selected option through its own controlled state, and on that CI run the assertion ran before the commit reached the input, so it still held the typed query. Fixed by driving the same two keys through userEvent and reading the commit through waitFor instead of assuming one microtask flush is enough — confirmed with 5 repeated local runs plus the full file (57/57).

I checked the rest of that file for the same shape (raw dispatchEvent plus an empty act() immediately followed by a state assertion) and found two more new KeyboardEvent/dispatchEvent call sites. Neither shares the race: one (a disabled picker: ... no key commit) asserts a stable negative on a disabled input, so there is no pending async commit to race against, and a disabled element can't be focused through userEvent the same way, so it isn't a mechanical conversion either. The other (T023-6: Home/End are NOT intercepted) needs the raw KeyboardEvent object's own defaultPrevented property after dispatch, which userEvent doesn't expose, and it already runs under fake timers wrapped in act(), not real-timer races. Left both alone.

Verification: npm run typecheck and the full npm test -- --run (3281 tests, 136 files) both green, the Playwright suite (8 tests, including the new real-server split-equivalence check) green against a rebuilt cw916 stack. Stack, image, and .tmp/ torn down; no stray worktrees.

mforce added a commit that referenced this pull request Sep 21, 2026
…dEntityPicker/FlockPicker (#918)

Assessment found no reason FlockPickerDialog needed to exist: the mockup's
own fidelity requirement — "All flocks" pinned above the results — is
already solvable through the picker's existing slots.paper mechanism (the
same one Load More renders through), and the original brief said to reuse
the picker in the first place. Dashboard now opens the shared Dialog with
FlockPicker inside it (the same nesting ExpensesPage already uses), and a
new optional `pinnedChoice` prop threads fixed content above the listbox
through NamedEntityPicker's engine — omitted by every other caller, so
Daily entry/Feed/Water/Expenses/History are unaffected. The engine's search
field also gets the 44px minHeight fix proven on the bespoke dialog, this
time applied once for all five callers.
@mforce

mforce commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

At 6c561bd: FlockPickerDialog is deleted. The Lay rate card's flock scope now opens the shared Dialog with FlockPicker inside it — the same nesting already live in ExpensesPage.tsx's correction dialog — instead of a bespoke component.

Which row covers this. docs/designs/822-mui-revamp.md's NamedEntityPicker↔Autocomplete row (pair 1, landed as #826/#898) already specifies the mechanism this screen needed: a custom slots.paper component wrapping the listbox, used today for the Load-more/status footer as a sibling of <ul role="listbox">. Pinning "All flocks" above the results is the same mechanism run in the other direction — a new optional pinnedChoice prop on the engine, rendered before {children} instead of only after. It threads through FlockPicker (pages never import the engine directly, per its own header) and is omitted by every other caller, so Daily entry, Feed, Water, Expenses and History are unaffected — confirmed by the full local suite and by NamedEntityPicker.test.tsx's own new "absent by default" case.

Why this matters more than the diff. Three defects from the last two rounds — the query-generation race on load-more, the missing initial focus on the search field, and the 37px tap target — all landed in mechanics NamedEntityPicker already had, reviewed and hardened, under #898 and #512. That's the concrete cost "reused, never rebuilt" is pricing: not a style preference, but three real defects in reimplemented surface area that a shared component had already paid for once. The 44px fix lands in the shared engine this time, not the bespoke dialog, so it benefits all five existing callers instead of one.

Open owner decision, deliberately not resolved here. The mockup's own hand-rolled picker moves Home/End as list navigation (jump to first/last choice); NamedEntityPicker deliberately keeps Home/End as native text-cursor movement inside the search field (FR-031, an existing spec-backed rule serving five other screens). This PR does not touch that contract — it stays exactly as the shared engine already implements it. If the mockup's Home/End behavior is wanted for this screen specifically, that is a call for the owner to make, not an implementer default.

Verification: full local suite (npm run typecheck clean, 3275 tests — 3274 passed, one pre-existing flake in DailyEntryPage.test.tsx unrelated to this diff, confirmed by two clean re-runs in isolation) plus a mutation check on the new pinnedChoice render path (removing it turns the new engine test red, confirmed, then reverted). Playwright against a rebuilt cw916 stack: dashboard-flock-scope.spec.ts (8/8, both viewport projects, including the real 44px measurement and the real-server split-equivalence check), named-entity-picker.spec.ts (3/3, confirming no regression on the engine's other real callers), phone.spec.ts (11/11). Stack, image and .tmp/ torn down after.

mforce added a commit that referenced this pull request Sep 21, 2026
The shared Dialog unmounts its children on close, so every reopen of the
Lay-rate flock picker was a fresh engine mount, and Dashboard never told it
what scope was already committed — every reopen showed aria-selected="false"
on the previously picked flock and aria-pressed="false" on "All flocks",
with no indicator of the active scope at all.

Fixed by threading `scope` through FlockPicker's existing
`controlledCommitted`/`controlledGeneration` props, plus `onClear` (wired to
the same reset "All flocks" performs, since seeding a committed entity also
surfaces the engine's own footer Clear link, previously dead code with
nothing scoped). Checked empirically rather than assumed: this does
pre-fill the reopened search field with the committed name, matching every
other FlockPicker/CustomerPicker caller's own reopen behaviour, and does
not narrow the results, since the seed only touches display text, never
the discovery filter.

Also corrects the "five callers" count from the prior round's comments to
the real total: eleven pre-existing render sites (nine FlockPicker, two
CustomerPicker) across seven screens.
@mforce

mforce commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Fixed at 1be88e0. Confirmed the reported P2: reopening the picker after a pick showed aria-selected="false" on the flock and aria-pressed="false" on "All flocks" — nothing indicated the active scope. Root cause matches your diagnosis: the shared Dialog unmounts its children on close (MUI's default keepMounted={false}), so every reopen is a fresh engine mount, and Dashboard never passed the current scope into it.

The fix. FlockPicker gets controlledCommitted={scope.kind === "flock" ? scope.flock : null} and a constant controlledGeneration={1} — the sync effect fires once per fresh mount regardless (its ref starts at -1), and scope never changes without the dialog also closing, so a constant is correct here, not a bug. Also wired onClear to the same reset "All flocks" performs: seeding a non-null controlledCommitted surfaces the engine's own footer Clear link (previously dead code, gated on a committed entity Dashboard never had), and leaving it unwired would have reintroduced a version of the same desync — the engine locally blanking its selection while scope stays stale.

On the query-seeding constraint. Checked against the DOM rather than assumed: seeding controlledCommitted does pre-fill the reopened search field with the committed flock's name (verified directly — expect(searchField).toHaveValue("Flock f2") in both the Vitest and Playwright suites). This is not a regression against this screen's own prior behavior — the bespoke dialog it replaced never had a committed-flock concept to seed from in the first place — and it matches how every other FlockPicker/CustomerPicker caller already behaves on reopen (#735's open-focus effect select-alls the pre-filled name, ready to be typed over). The results are not narrowed by it: the sync effect only writes discovery.rawQuery (display text), never normalizedQuery (the actual discovery filter), so the open fetch still returns the full unfiltered list — also asserted directly.

Regression tests, both states, each mutation-verified (Dashboard.test.tsx):

  • marks the previously picked flock as the active option when the picker is reopened — reverting controlledCommitted to a bare null turns this red (aria-selected stays false), confirmed then reverted.
  • marks All flocks as the active choice when the picker is reopened after clearing a flock pick — the symmetric case. I should be transparent about this one: aria-pressed on the pinned button was already correctly driven by scope.kind === "all" directly, never by the missing wiring, so I could not construct a mutation that makes this fail independently of the first test. It pins the correct contract per your "cover both states" instruction, but isn't itself independently regression-differentiating — flagging that rather than asserting it's equally load-bearing.
  • seeds the reopened search field with the committed flock's name... and does not narrow the results by it — the empirical check the constraint asked for.
  • treats the newly-visible Clear link the same as picking All flocks — covers the onClear side effect; reverting it turns this red (the dialog never closes, test times out), confirmed then reverted.

Same four scenarios re-verified against a rebuilt, real-browser cw916 stack in dashboard-flock-scope.spec.ts (real MUI Autocomplete aria-selected/aria-pressed computation, not jsdom) — one test-authoring bug found and fixed along the way (my own test tried to reopen a dialog that was still open from the prior step; fixed, not an app defect).

Corrected the caller count per your note: "all five callers" → eleven pre-existing render sites (nine FlockPicker, two CustomerPicker) across seven screens (Daily entry, History, Feed ×2, Water ×2, Users, Expenses ×2, Sales ×2), twelve with this PR's own Dashboard usage. Fixed in the PR body and in the two source comments that repeated the wrong number.

Scope discipline held: Home/End untouched, pinnedChoice's own API unchanged (this fix only adds controlledCommitted/controlledGeneration/onClear, all pre-existing FlockPicker props Dashboard simply wasn't using before).

CI: npm run typecheck clean, full web suite 3279/3279. Playwright against the rebuilt isolated stack: dashboard-flock-scope.spec.ts 9/9 (both viewport projects), named-entity-picker.spec.ts 3/3 (no regression on the engine's other real callers). Stack, image and .tmp/ torn down after. Pushing now; not merging.

…omplete

dayStrip() fell straight to a null peak whenever no day in the window was
fully recorded, so height() drew every bar at the 2% floor regardless of the
real totals behind them -- the exact symptom from #916 (a farm where 2 of 102
houses file shows every bar as a hairline stub despite a real 738-egg day).

Add a "partial" fallback: when no complete day exists but at least one day
has a recorded figure, scale against the largest of those instead. The
complete-day-only average is untouched. A new `scale` field on DayStripData
("complete" | "partial" | "none") lets the caption say which pool is in use.
GET /api/v1/reports/production takes an optional flockId. The query narrows
every scan in ReportQueries.GetProductionAsync (eggs, grade totals, and the
hen-day exposure walk) together -- narrowing eggs without narrowing exposure
would rate a healthy flock at a fraction of its real lay, the #780 defect in
miniature. The endpoint checks IFlockRepository.GetByIdAsync first and
returns 404 for a flock the caller cannot see, reusing FlockEndpoints' own
existence-check pattern (the structural AccountId+flock-scope query filter is
what makes that check double as the access check).

Adds the FlockManagement reach the module ledger and coupling matrix now
require, and three integration tests: scoped-vs-farm-wide partition, a
flock outside the caller's own flock scope, and a flock from another
tenant or naming nothing at all.
…916)

Multiple accessible flocks: an All flocks toggle beside the existing
NamedEntityPicker/FlockPicker (MUI Autocomplete, #898), All flocks kept
outside the picker's own scrolling results per the approved mockup. Exactly
one accessible flock: its name as plain text, no picker. The whole card --
bars, completeness, the complete-day average, and both hen-day comparison
periods -- follows the chosen scope; every other Dashboard panel is
unaffected, and the report fetch for the two hen-day windows is now a
separate effect so a scope change never re-fetches them.

With exactly one accessible flock, the sole flock's id feeds the SAME
getProductionReport(from, to, flockId) call a manual pick would produce --
structural parity between the "only one" and "selected single" views, per
SELECTION.md.

Docs: a GLOSSARY.md entry for the strip's scale fallback and for the flock
scope, an in-app glossary entry in en/es/tl, and an addition to the existing
Dashboard help text.
… the aria mismatch (#916)

Three defects found by end-to-end browser evidence against the isolated
simulation stack, none caught by the unit suite:

- The All flocks button was 36px tall, under the repo's 44px tap-target
  floor; phone.spec.ts's standing guard (every visible link/button in `main`)
  went red against it.
- The scope picker's onCommit never closed the search, unlike every other
  FlockPicker caller in the app (Daily entry, Feed, Water, Expenses,
  History) -- confirmed live, not assumed from a prior code read.
- trendLabel's accessible sentence branched on `max === null`, which the
  scale-rule fix already made true for BOTH "no data" and "partial
  fallback" -- so a screen reader was told "no peak or average" over a
  window whose visible caption and Peak figure showed a real number.
  Rewritten to branch on `scale` directly; trendStripLabelNoComplete is now
  provably unreachable and removed, replaced by trendStripLabelPartialScale.

Adds tools/simulation/ui/specs/dashboard-flock-scope.spec.ts: the fixture's
own farm-wide partial-day fallback, server-side rescoping through the
picker, the one-flock plain-text state (restrictedWorker(), whose reads
turn out to be flock-scoped too -- #613's structural filter, not the
write-only behavior src/cast.ts's older comment describes), the
only-one/selected-single report parity across two principals, and a
@phone-tagged layout check. Verified against a rebuilt isolated stack:
5/5 new tests, 62/63 full smoke suite (1 pre-existing skip), the 44px
guard, and tsc --noEmit all green.
Drop a no-op onSnapshot handler FlockPicker already defaults on its own,
and fix a stale comment in dayStrip's height() that still said "the
complete-day peak" after the fallback made that only sometimes true.
…dings

Fidelity round on PR #918 (owner directive: the mockup is the spec, #901).
The Lay rate card's flock scope now matches
docs/designs/916-production-scale/production-flock-selector-v2.html in DOM
order and control shape rather than approximating it:

- One full-width selector (eyebrow "Flock" + value + chevron) replaces the
  separate All-flocks chip beside an MUI Autocomplete. It opens a picker
  dialog built to the mockup's own shape -- a 44px close button, a labelled
  search input filtering the already-loaded flock list client-side, and an
  "All flocks" choice pinned ABOVE the scrolling result list, never a row
  inside it.
- A context caption ("{N} accessible flocks / the flock's name * range")
  sits under the selector.
- The scale caption now reads "Eggs per day * complete-day scale" /
  "* partial days only" / "* no recorded figures", and Peak/Avg always
  render a sentence ("Peak --", "No complete-day average") instead of
  hiding when null.
- DayStrip gains the mockup's three-item legend (Complete/Partial/No entry).
- The hen-day KPI moves from the top of the card to the bottom, after the
  strip, on both desktop and phone.

Also folds in three findings from Codex's review of c5d60d4:
- The picker's only reset path (choosing "All flocks" inside the dialog)
  sets scope and the displayed value from the SAME state now, so there is
  no second "committed" mirror left to desync (finding 1).
- The page-level "everything failed" decision now waits for the production
  report's own first outcome too, not just the four panel reads, so a farm
  where only those four fail no longer hides an already-loaded Lay rate
  card behind the full-page error; `loading` itself still resolves as soon
  as the four settle, so a slow production fetch never blocks the page
  (finding 2).
- A new test drives an explicit stale-response race (older scope resolves
  after a newer one) and asserts the newer figures survive; verified by
  deleting the trend effect's `cancelled` guard and confirming the test
  goes red, then reverting (finding 3).

Web suite: 3148/3148 passing, typecheck clean. Playwright, rebuilt isolated
stack: the flock-scope spec's 6 tests (5 desktop + 1 @phone) and the full
63-test quick smoke suite (1 pre-existing skip) all green.
… generation, add picker keyboard nav and a flock-list-unavailable state (#918)

Codex review, round 3 (c5d60d4..f6169b8): the dialog's search only
filtered the first 500 already-loaded flocks, so a flock past that page
was unreachable on a large farm — search is now server-paged and
debounced (50 rows/250ms), matching the shared picker engine. The
"everything failed" gate kept its first-ever panel/trend outcome
forever, so an earlier failure could outlive a later refresh that
succeeded (a role change flipping canSeeSales) — both outcomes are now
keyed by a load generation counter. The results list gained
Arrow/Home/End/Enter roving focus, and a failed flock-list read now
shows the existing panel-error pattern with Retry instead of reading as
an empty farm.
…eneration, and rerun production on a role change (#918)

Codex review, round 4 (f6169b8..HEAD): the picker's own state (search,
paging, keyboard nav) moves into a new FlockPickerDialog component with
its own tests, so Dashboard.tsx only holds whether the dialog is open
and the scope a pick produced. Within it: extension requests and the
debounced search are now tied to a query generation, dropping a stale
or closed-dialog response instead of corrupting the results/cursor; a
failed discovery request shows its own unavailable state with Retry
instead of reading as a real empty result; and keyboard roving focus
skips a disabled Load more button.

In Dashboard.tsx: the production-report effect now also reruns on a
role change alone (canSeeSales), which the panels effect's own
generation bump already did — without it, a role change with the
flock set unchanged left no trend outcome recorded for the new
generation, silently swallowing a genuine total failure. Retry on a
failed flock-list read no longer clears the unavailable state before
the retried read settles.
…re against a stale query or a closed dialog (#918)

Codex review, round 5 (bbc134e..bbaf1ef): the round-4 extraction had
narrowed the keyboard test down to the disabled-Load-more boundary
case alone, dropping the Arrow/Home sweep, the search-to-results
ArrowDown handoff, and Escape's return-focus-to-trigger behavior — all
restored in one test against a real trigger button. The generation-race
test covered a replacement search only; a Load More extension left
pending across a query change, or across the dialog closing and
reopening, was unguarded. Both gaps are test-only: the product code
already had the line-104 generation guard from round 4.
Trim review-round narration from the production code: comments
re-litigating what an earlier draft of this same unmerged branch did
wrong are archaeology a future reader cannot act on, while the
invariants they surround are kept. Dashboard.tsx was 27% comment lines
against main's 12%; it is now 20%, on a file that grew by a subsystem.

Also folds the duplicated `soleFlock`/`soleFlockId` expressions into one
derivation. No behaviour change: typecheck clean, 3159 Vitest tests pass.
The first pass measured density and stopped there, leaving 18 branch-added
comment blocks of 5+ lines. Cuts the spec header from 25 lines to 12 and
removes the review-round narration the i18n catalog still carried.
…918)

Deslop follow-up: measuring comment density against main caught the
overall increase but missed block LENGTH — several branch-added blocks
still ran 5-9 lines. Sixteen such blocks across Dashboard.tsx,
FlockPickerDialog.tsx, the Playwright spec and lib/dashboard.ts are now
at or under 4 lines, with the load-bearing invariants (the total-failure
verdict spanning both effects and keyed to loadGenRef; why the picker's
results/cursor clear in the same tick as a query change) kept intact.
lib/dashboard.ts's pre-existing half (from main) is untouched; only the
branch-added tail was trimmed.
…t-side

The picker's search became a server-paged `listFlocks` call in the round-3
fix; this comment still described the pre-round-3 client-side filter, which
is the opposite of what #916's API half exists to guarantee.
Three statements of the 2%-floor rationale in dashboard.ts collapse to one,
`max` and `scale` stop pointing at each other in a circle, the picker-catalog
note stops being made twice, and the new file drops its filename banner —
only 5 of 104 non-test files carry one, so it is not a convention.
…a stale retry verdict, cancel superseded reports, and be honest about a truncated flock count (#918)

First proper review seat on #918, found three P2s and two P3s:

- P2-1: the Morning collection panel's "Yesterday by close" caption
  derived from the SAME scoped trend the Lay rate card reads, so
  picking a flock changed a different panel's figure. It is now its
  own always farm-wide, single-day fetch, decoupled from scope.
- P2-2: a successful flock-list Retry never updated the page-level
  failure verdict, so a stale "all four panels failed" record could
  survive a recovered flock list and hide the whole dashboard once the
  auto-triggered sole-flock production read then failed. The
  generation-ref machinery is replaced with two outcome states
  (pending/someOk/allFailed, pending/success/failure) that Retry now
  updates directly.
- P2-3: a superseded production request was only ignored, never
  aborted, so it could sit in flight holding a report-concurrency
  permit. The trend effect now uses an AbortController per dispatch,
  threaded through apiGet/getProductionReport.
- P3-4: the picker dialog focused its close button on open instead of
  the search box, and the search box was under the 44px touch-target
  floor. Fixed with autoFocus and a min-height override on the input
  wrapper.
- P3-5: the accessible-flock count presented as exact past
  listFlocks's 500-row cap. It now reads "500+" once truncated.

Every fix carries a test that fails on the pre-fix code and a local
mutation check confirming it; see the PR reply for the exact evidence.
… CI flake in a picker test the branch didn't introduce (#918)

A dashboard load fired three /reports/production requests (two
adjacent trend windows plus the new yesterday-close fetch), three of
the account's four shared report-concurrency permits
(RateLimitingOptions.ReportsConcurrency: PermitLimit 4, QueueLimit 0,
no queue — anything past the cap 429s rather than waiting). Two
workers on the same farm opening the app together could exceed it.

The two adjacent trend windows (current week, previous week) are now
one daysBefore(today,14)..daysBefore(today,1) request, split
client-side by the new splitProductionReport. Every report-level
total is a plain sum over days[], computed the identical way
server-side (ReportQueries.GetProductionAsync), so summing a subset of
an already-fetched period reproduces exactly what a second request for
that subset would have returned — verified both by a fast unit test
against a hand-built fixture and, more importantly, against the real
simulation server's own combined-vs-separate responses. A test pins
the request count at two per load so the ceiling cannot creep back.

Also fixes an unrelated CI flake in NamedEntityPicker.test.tsx exposed
by this branch's CI run: a raw KeyboardEvent dispatch plus an empty
act() raced MUI Autocomplete's controlled-state commit. Converted to
userEvent + waitFor; two other raw-dispatch tests in that file were
checked and are not the same race (one asserts a stable negative on a
disabled control, the other needs the raw event's defaultPrevented,
which userEvent does not expose), so they were left alone.
…dEntityPicker/FlockPicker (#918)

Assessment found no reason FlockPickerDialog needed to exist: the mockup's
own fidelity requirement — "All flocks" pinned above the results — is
already solvable through the picker's existing slots.paper mechanism (the
same one Load More renders through), and the original brief said to reuse
the picker in the first place. Dashboard now opens the shared Dialog with
FlockPicker inside it (the same nesting ExpensesPage already uses), and a
new optional `pinnedChoice` prop threads fixed content above the listbox
through NamedEntityPicker's engine — omitted by every other caller, so
Daily entry/Feed/Water/Expenses/History are unaffected. The engine's search
field also gets the 44px minHeight fix proven on the bespoke dialog, this
time applied once for all five callers.
The shared Dialog unmounts its children on close, so every reopen of the
Lay-rate flock picker was a fresh engine mount, and Dashboard never told it
what scope was already committed — every reopen showed aria-selected="false"
on the previously picked flock and aria-pressed="false" on "All flocks",
with no indicator of the active scope at all.

Fixed by threading `scope` through FlockPicker's existing
`controlledCommitted`/`controlledGeneration` props, plus `onClear` (wired to
the same reset "All flocks" performs, since seeding a committed entity also
surfaces the engine's own footer Clear link, previously dead code with
nothing scoped). Checked empirically rather than assumed: this does
pre-fill the reopened search field with the committed name, matching every
other FlockPicker/CustomerPicker caller's own reopen behaviour, and does
not narrow the results, since the seed only touches display text, never
the discovery filter.

Also corrects the "five callers" count from the prior round's comments to
the real total: eleven pre-existing render sites (nine FlockPicker, two
CustomerPicker) across seven screens.
@mforce
mforce force-pushed the feat/916-production-scale branch from 1be88e0 to f1837a3 Compare September 21, 2026 13:32
@mforce

mforce commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main's current head (8332b36, both #927 and #929) and pushed once at f1837a3 — no separate push-then-rebase.

#927 (Sales order desk). Confirmed before rebasing: it does not touch NamedEntityPicker.tsx. The rebase applied clean across all 18 commits with zero conflicts, including in web/src/i18n/{en,es,tl}.ts, which both #927 and this branch modify (different keys).

#929 (Node/npm pin). Reinstalled under the pinned toolchain (node -v → v26.9.0, npm -v → 11.19.1, matches the engines field) rather than assuming the shell already had it. web/package-lock.json carries 6 "libc" fields post-rebase, matching what a clean npm ci under main's own pin produces — not hand-verified against a diff, but confirmed by running npm ci itself, which would hard-fail (EBADENGINE) on a wrong toolchain and didn't.

Full suite on the rebased branch (measured now, not carried over from before the rebase): npm run typecheck clean. npm test -- --run: 3327/3327 passing across 136 files (up from 3279/135 pre-rebase — #927 added sales-layout.spec.ts and expanded SalesPage.test.tsx). Playwright against a stack rebuilt from the rebased head: dashboard-flock-scope.spec.ts 9/9 (both viewport projects), named-entity-picker.spec.ts 3/3, plus a sanity pass on sales.spec.ts/sales-layout.spec.ts (5/5) given #927's own scope — all green, no interaction with this branch's NamedEntityPicker/FlockPicker changes. Stack, image and .tmp/ torn down after.

CI is running fresh on f1837a3 now. Not merged.

@mforce
mforce merged commit f4fa057 into main Sep 21, 2026
16 checks passed
@mforce
mforce deleted the feat/916-production-scale branch September 21, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web: Dashboard production strip collapses to hairline bars when no day in the window is complete

1 participant