diff --git a/tools/simulation/ui/mutation-check.sh b/tools/simulation/ui/mutation-check.sh
index 200bac3d..9ea0a8c8 100755
--- a/tools/simulation/ui/mutation-check.sh
+++ b/tools/simulation/ui/mutation-check.sh
@@ -261,21 +261,22 @@ declare -A FALSE_KILLS=(
# does — this mutant still dies inside sign-in, still proves nothing about the
# nav gate, and is still counted as a false kill rather than as coverage.
#
-# The three phone entries were observed the same way. Two of them carry a custom
-# message; `phone-table-overflow-unclipped` declares TWO lines, one per route it
-# still breaks, because the softness of that walk is itself the claim — a hard
-# assertion would stop at /sales and report half the damage, so requiring both
-# is what keeps `expect.soft` there honest. /daily-entry and /stock are
-# deliberately absent: neither renders a wide data table, and both stayed at
-# exactly 390 under the mutant. /customers and /flocks were also on this list
-# until #832: the mutant's CSS targets `table.data` specifically, and #832
-# moved both routes onto MUI's `TableContainer`, which the mutant's rule does
-# not reach — observed directly (`CLUCKWORK_E2E_MUTANT=phone-table-overflow-unclipped`
-# against a #832 build): only /sales and /history still overflow. This is a
-# real narrowing of what the mutant proves, not a typo; if a later slice moves
-# /sales or /history onto MUI too, this mutant stops proving anything at all
-# and needs a new CSS target (MUI's `TableContainer`, not `table.data`) or
-# retirement, matching #824's "retire only with a named successor" rule.
+# The three phone entries were observed the same way. One of them carries a
+# custom message; `phone-table-overflow-unclipped` declares ONE line now
+# (narrowed from two in #832 to one in #831 — see below), because the
+# softness of that walk is itself the claim — a hard assertion would stop at
+# the first offender and report only part of the damage, so requiring the
+# line that remains is what keeps `expect.soft` there honest. /daily-entry
+# and /stock are deliberately absent: neither renders a wide data table (Stock
+# moved to MUI's `Table` in #831, same shape as Customers/Flocks below), and
+# both stayed at exactly 390 under the mutant. /customers and /flocks were
+# also on this list until #832, and /history until #831: the mutant's CSS
+# targets `table.data` specifically, and each of those slices moved its route
+# onto MUI's `TableContainer`, which the mutant's rule does not reach. This is
+# a real narrowing of what the mutant proves, not a typo — only /sales still
+# overflows under it now, and #831's own follow-up (Sales) needs a new CSS
+# target (MUI's `TableContainer`, not `table.data`) or retirement, matching
+# #824's "retire only with a named successor" rule, once it lands too.
#
# The two phone action mutants split the walk's rule between them, and each
# declares only what it can actually redden. #823 stacks every action row below
@@ -332,8 +333,7 @@ taller than it is wide, so its pill clamps into an ellipse"
[phone-entry-foot-stacked]="in the daily-entry save bar spans"
[phone-dialog-footer-stacked]="dialog footer's row is not laid out as a row (computed flex-direction: column)
dialog footer's buttons share no common vertical band"
- [phone-table-overflow-unclipped]="/sales scrolls sideways at phone width
-/history scrolls sideways at phone width"
+ [phone-table-overflow-unclipped]="/sales scrolls sideways at phone width"
)
MUTANTS=("$@")
diff --git a/tools/simulation/ui/specs/phone.spec.ts b/tools/simulation/ui/specs/phone.spec.ts
index 9f018b51..5a329540 100644
--- a/tools/simulation/ui/specs/phone.spec.ts
+++ b/tools/simulation/ui/specs/phone.spec.ts
@@ -557,8 +557,10 @@ test.describe("Phone shell", { tag: "@phone" }, () => {
// either way, `table.data` or MUI's.
{ path: "/customers", content: "role=table", what: "the customer book" },
{ path: "/flocks", content: "role=table", what: "the flock table" },
- { path: "/stock", content: "table.data", what: "the stock table" },
- { path: "/history", content: "table.data", what: "the entry history table" },
+ // #831 — Stock and History moved their table onto MUI's `Table` too,
+ // same reasoning as Customers/Flocks above.
+ { path: "/stock", content: "role=table", what: "the stock table" },
+ { path: "/history", content: "role=table", what: "the entry history table" },
];
for (const { path: route, content, what } of ROUTES) {
diff --git a/tools/simulation/ui/src/mutants.ts b/tools/simulation/ui/src/mutants.ts
index 3adb775f..9d0fd47f 100644
--- a/tools/simulation/ui/src/mutants.ts
+++ b/tools/simulation/ui/src/mutants.ts
@@ -1121,19 +1121,25 @@ export const MUTANTS: Record = {
+ "so an unconverted screen's table lays its full content width out into the page instead of "
+ "scrolling within itself. #832 gave `Customers`/`Flocks` (and `Products`/`Grades`/`Users`) "
+ "the same containment through a different mechanism — a `MuiTableContainer` theme override, "
- + "not this class — so this mutant's `table.data`-scoped rule no longer reaches them; see the "
- + "note on EXPECT_MSG_FOR in mutation-check.sh.",
+ + "not this class — and #831 did the same for `Stock`/`History` — so this mutant's "
+ + "`table.data`-scoped rule no longer reaches any of them; see the note on EXPECT_MSG_FOR in "
+ + "mutation-check.sh.",
caughtBy: "phone.spec.ts — no walked screen overflows the viewport horizontally",
apply: (page) =>
- // Two of the six walked routes overflow under this now — /sales and
- // /history — and four do not: /daily-entry and /stock render no wide
- // data table, and /customers and /flocks moved off `table.data` in
- // #832 (see `breaks` above). That per-route spread is why the spec's
- // walk asserts PER ROUTE and asserts SOFTLY: a hard assertion stops at
- // the first and reports half the damage. The exact widths are
- // deliberately not recorded here; they drift with fixture content, and
- // a stale copy of them in this file is a defect this file has already
- // had once.
+ // Only ONE of the six walked routes overflows under this now — /sales
+ // — narrowed from two (/sales and /history) once #831 converted
+ // History. /daily-entry and /stock render no wide data table (Stock
+ // moved off `table.data` in #831 too); /customers, /flocks and
+ // /history moved onto MUI's `TableContainer` (#832, #831 — see
+ // `breaks` above), which this mutant's rule does not reach. That
+ // per-route spread is why the spec's walk asserts PER ROUTE and
+ // asserts SOFTLY: a hard assertion stops at the first and reports
+ // only part of the damage. The exact widths are deliberately not
+ // recorded here; they drift with fixture content, and a stale copy of
+ // them in this file is a defect this file has already had once. Once
+ // /sales converts too this mutant proves nothing at all and needs a
+ // new CSS target (MUI's `TableContainer`) or retirement (#824's
+ // "retire only with a named successor" rule).
//
// Desktop-green, stated honestly rather than claimed as containment:
// the rule is inside `@media (max-width: 900px)`, so it cannot apply at
diff --git a/web/src/components/EntryRow.tsx b/web/src/components/EntryRow.tsx
new file mode 100644
index 00000000..40f20a81
--- /dev/null
+++ b/web/src/components/EntryRow.tsx
@@ -0,0 +1,108 @@
+import { Box, Typography } from "@mui/material";
+import type { ReactNode } from "react";
+import { remainderDropProps } from "./GradingChip";
+
+// #830/#831 — shared between Daily entry's capture form and History's adjust
+// dialog: both render the identical two-step egg-counts/grading layout (the
+// dialog IS that form, per History's own copy), so the alignment fix below
+// has to live in one place or drift the moment either screen's rows change.
+//
+// #830 (owner's screenshot review of #888) — the stepper row's 48px squares
+// (mockup: docs/designs/864-visual-language/daily-entry.html) are an sx
+// override on NumberField's OWN classes (`.numfield-step`), never an edit to
+// NumberField.tsx or its base CSS block (styles.css, #828's): those stay
+// exactly as #828 left them, and this override reaches only rows rendered
+// through EntryRow. Every part NumberField renders is a FIXED size at a given
+// breakpoint — the two step buttons, and the input's own ch-width — so
+// `.numfield`'s overall footprint is constant across every row; that
+// constancy is what the grid below leans on to line the minus/plus buttons
+// up without touching NumberField itself.
+export const STEPPER_SX = {
+ "& .numfield": { width: "100%", justifyContent: "space-between" },
+ "& .numfield-step": {
+ width: { xs: 48, md: 36 }, height: { xs: 48, md: 36 },
+ borderRadius: "var(--r-input)",
+ },
+ "& .numfield input": {
+ // The row numeral size (FarmThemeProvider's `h2`/title scale, DIRECTION.md
+ // — 24/28 desktop, 28/32 phone), not a bespoke size: the readout is the
+ // biggest thing in the row and reads as one more title-weight figure
+ // beside the others this screen shows (the sellable value, the grading
+ // count), right-aligned and tabular so a column of them lines up by digit.
+ fontSize: { xs: "1.75rem", md: "1.5rem" },
+ lineHeight: { xs: "2rem", md: "1.75rem" },
+ fontWeight: 500, textAlign: "right",
+ // Wide enough for a 4-digit count (a flock's daily total can run into the
+ // low thousands) with room to spare — measured against "430" clipping to
+ // "43" at a tighter "4ch" on desktop (Playwright capture, #830).
+ width: { xs: "5.5ch", md: "6ch" },
+ },
+} as const;
+
+// #830 (owner's screenshot review of #888) — one ruled GRID row: label (+
+// optional caption, e.g. "deactivated") in a flexible truncating column,
+// stepper in a fixed-content column, per the mockup's `.row`. The row used to
+// be a flex `justify-content: space-between` pair, which reads as aligned
+// only until a label overflows: a flex item shrinks by default, so "Total
+// eggs" wrapping onto two lines squeezed the stepper beside it by a different
+// amount on every row — the owner's screenshot review of #888 caught this as
+// each row's minus button sitting at a different x. A grid's second column
+// sizes to its own max-content and does NOT shrink to make room for an
+// overflowing sibling; pairing that with `minmax(0, 1fr)` + an ellipsis on
+// the label (never wrap) is what makes the fix structural rather than a
+// pinned width. `groupLabel` names a grade row as an `aria-label`ed group
+// (mirrors Dashboard's TodayRow `role="group"` pattern) — the drop target the
+// test suite locates by name instead of a class, and `armed` draws the F134
+// "taking" outline the same rows carried before, now an inline sx state
+// instead of a shared `.taking` class.
+export function EntryRow({
+ htmlFor, label, caption, groupLabel, armed = false, dropProps, children,
+}: {
+ htmlFor: string;
+ label: string;
+ caption?: string;
+ groupLabel?: string;
+ armed?: boolean;
+ dropProps?: ReturnType;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+ {caption && (
+
+ {caption}
+
+ )}
+
+
+ {children}
+
+
+ );
+}
diff --git a/web/src/components/FilterBar.test.tsx b/web/src/components/FilterBar.test.tsx
new file mode 100644
index 00000000..85c8fc63
--- /dev/null
+++ b/web/src/components/FilterBar.test.tsx
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { FilterBar, FilterDateField } from "./FilterBar";
+
+describe("FilterBar", () => {
+ it("renders every child control, each reachable by its own label", () => {
+ render(
+
+ {}} />
+ {}} />
+ ,
+ );
+ expect(screen.getByLabelText("From", { exact: true })).toHaveValue("2026-01-01");
+ expect(screen.getByLabelText("To", { exact: true })).toHaveValue("2026-01-31");
+ });
+
+ it("lays the row out as a wrapping flex row, never a column, at rest", () => {
+ render(
+
+ {}} />
+ ,
+ );
+ // The Stack is the immediate child of the outlined Paper.
+ const paper = screen.getByLabelText("From").closest(".MuiPaper-root");
+ expect(paper).not.toBeNull();
+ expect(paper).toHaveClass("MuiPaper-outlined");
+ const stack = paper!.querySelector(":scope > .MuiStack-root");
+ expect(stack).not.toBeNull();
+ expect(stack).toHaveStyle({ flexWrap: "wrap" });
+ });
+
+ it("a date field reports the value change the caller's onChange receives", () => {
+ const onChange = vi.fn();
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByLabelText("From", { exact: true }), { target: { value: "2026-02-01" } });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ // CodeRabbit on #901 (FilterBar cherry-picked into Audit): `{ ...sx }` only
+ // spreads a plain object — a theme-callback `sx` function or an `sx` array
+ // has no own enumerable properties to spread, so either was silently
+ // dropped. `sx` accepts both shapes; a caller passing a function must still
+ // see it applied alongside the field's own bounded-width default.
+ it("still applies a caller's function-form sx alongside the bounded-width default", () => {
+ render(
+
+ {}}
+ sx={() => ({ color: "rgb(1, 2, 3)" })}
+ />
+ ,
+ );
+ const field = screen.getByLabelText("From", { exact: true }).closest(".MuiFormControl-root");
+ expect(field).not.toBeNull();
+ expect(field).toHaveStyle({ color: "rgb(1, 2, 3)" });
+ });
+});
diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx
new file mode 100644
index 00000000..7543f8dd
--- /dev/null
+++ b/web/src/components/FilterBar.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode } from "react";
+import { Paper, Stack, TextField } from "@mui/material";
+import type { TextFieldProps } from "@mui/material";
+
+/**
+ * #831/#653 — the shared filter row every ledger screen (Sales, Stock,
+ * Inventory, History, Expenses, Feed, Water, Reports) and Audit (#833) mount
+ * above their table. `variant="outlined"` is load-bearing: #651 D1 measured
+ * `--surface-2` against `--canvas` at 1.05:1-1.21:1 in every palette and
+ * mode, too close to read as an edge without the hairline border.
+ */
+export function FilterBar({ children }: { children: ReactNode }) {
+ return (
+
+ *": { flex: { xs: "1 1 100%", md: "0 0 auto" } },
+ }}
+ >
+ {children}
+
+
+ );
+}
+
+const DATE_FIELD_MAX_WIDTH = "12rem";
+
+/**
+ * A date control sized for a FilterBar. Bounded at #653's 12rem from `md` up
+ * (two ten-character dates do not need the row's full width); the FilterBar
+ * itself widens it back to one control per line below that, per D3.3.
+ */
+export function FilterDateField({ sx, slotProps, ...props }: TextFieldProps) {
+ return (
+
+ );
+}
diff --git a/web/src/routes/DailyEntryPage.tsx b/web/src/routes/DailyEntryPage.tsx
index b5b60e66..27394d73 100644
--- a/web/src/routes/DailyEntryPage.tsx
+++ b/web/src/routes/DailyEntryPage.tsx
@@ -1,5 +1,5 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
-import type { FormEvent, ReactNode } from "react";
+import type { FormEvent } from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { Box, Button, Paper, TextField, Typography, useMediaQuery } from "@mui/material";
@@ -12,6 +12,7 @@ import { ApiError } from "../api/client";
import { useFormat } from "../farm/useFormat";
import { rememberFlockId, resolveDefaultFlock } from "../lib/flockDefault";
import { BusyButton } from "../components/BusyButton";
+import { EntryRow } from "../components/EntryRow";
import { FlockPicker } from "../components/FlockPicker";
import type { PickerSnapshot } from "../components/NamedEntityPicker";
import { Dialog } from "../components/Dialog";
@@ -31,107 +32,6 @@ import { useMe } from "../session/SessionContext";
import i18n from "../i18n";
import { statusLabel } from "../i18n/enums";
-// #830 (owner's screenshot review of #888) — the stepper row's 48px squares
-// (mockup: docs/designs/864-visual-language/daily-entry.html) are an sx
-// override on NumberField's OWN classes (`.numfield-step`), never an edit to
-// NumberField.tsx or its base CSS block (styles.css L1030-1093, #828's):
-// those stay exactly as #828 will find them, and this override reaches only
-// rows rendered by THIS page. Every part NumberField renders is a FIXED size
-// at a given breakpoint — the two step buttons, and the input's own ch-width
-// — so `.numfield`'s overall footprint is constant across every row; that
-// constancy is what EntryRow's grid below leans on to line the minus/plus
-// buttons up without touching NumberField itself.
-const STEPPER_SX = {
- "& .numfield": { width: "100%", justifyContent: "space-between" },
- "& .numfield-step": {
- width: { xs: 48, md: 36 }, height: { xs: 48, md: 36 },
- borderRadius: "var(--r-input)",
- },
- "& .numfield input": {
- // The row numeral size (FarmThemeProvider's `h2`/title scale, DIRECTION.md
- // — 24/28 desktop, 28/32 phone), not a bespoke size: the readout is the
- // biggest thing in the row and reads as one more title-weight figure
- // beside the others this screen shows (the sellable value, the grading
- // count), right-aligned and tabular so a column of them lines up by digit.
- fontSize: { xs: "1.75rem", md: "1.5rem" },
- lineHeight: { xs: "2rem", md: "1.75rem" },
- fontWeight: 500, textAlign: "right",
- // Wide enough for a 4-digit count (a flock's daily total can run into the
- // low thousands) with room to spare — measured against "430" clipping to
- // "43" at a tighter "4ch" on desktop (Playwright capture, #830).
- width: { xs: "5.5ch", md: "6ch" },
- },
-} as const;
-
-// #830 (owner's screenshot review of #888) — one ruled GRID row: label (+
-// optional caption, e.g. "deactivated") in a flexible truncating column,
-// stepper in a fixed-content column, per the mockup's `.row`. The row used to
-// be a flex `justify-content: space-between` pair, which reads as aligned
-// only until a label overflows: a flex item shrinks by default, so "Total
-// eggs" wrapping onto two lines squeezed the stepper beside it by a different
-// amount on every row — the owner's screenshot review of #888 caught this as
-// each row's minus button sitting at a different x. A grid's second column
-// sizes to its own max-content and does NOT shrink to make room for an
-// overflowing sibling; pairing that with `minmax(0, 1fr)` + an ellipsis on
-// the label (never wrap) is what makes the fix structural rather than a
-// pinned width. `groupLabel` names a grade row as an `aria-label`ed group
-// (mirrors Dashboard's TodayRow `role="group"` pattern) — the drop target the
-// test suite locates by name instead of a class, and `armed` draws the F134
-// "taking" outline the same rows carried before, now an inline sx state
-// instead of a shared `.taking` class.
-function EntryRow({
- htmlFor, label, caption, groupLabel, armed = false, dropProps, children,
-}: {
- htmlFor: string;
- label: string;
- caption?: string;
- groupLabel?: string;
- armed?: boolean;
- dropProps?: ReturnType;
- children: ReactNode;
-}) {
- return (
-
-
- {label}
- {caption && (
-
- {caption}
-
- )}
-
-
- {children}
-
-
- );
-}
-
-
// Capture targets active flocks plus depleted ones — a depleted flock still
// accepts backfilled entries up to its depletion date (the API gates exact
// dates), matching the Flocks screen's promise and the feed-usage picker.
diff --git a/web/src/routes/ExpensesPage.test.tsx b/web/src/routes/ExpensesPage.test.tsx
index 958ff95f..74877123 100644
--- a/web/src/routes/ExpensesPage.test.tsx
+++ b/web/src/routes/ExpensesPage.test.tsx
@@ -424,6 +424,19 @@ describe("ExpensesPage category filter", () => {
}),
);
});
+
+ // Both the filter's and the record-expense form's Category select start at
+ // "" with a placeholder option, so without an explicit shrink the label
+ // sat on top of that text. jsdom cannot show the overlap; the shrink class
+ // is the DOM fact that stands in for it (same pattern as #897's Grade
+ // select and #833's Audit filters).
+ it("shrinks both Category selects' labels instead of sitting them on top of their placeholder text", async () => {
+ mockListExpenses.mockResolvedValue(emptyList("USD", 2));
+ await renderReady("USD");
+ const labels = screen.getAllByText("Category", { selector: "label" });
+ expect(labels.length).toBeGreaterThan(0);
+ for (const label of labels) expect(label).toHaveClass("MuiInputLabel-shrink");
+ });
});
describe("ExpensesPage pagination", () => {
@@ -1116,14 +1129,15 @@ describe("ExpensesPage total is never a guess (#469, codex P2)", () => {
});
describe("ExpensesPage date-range filter (#667)", () => {
- // #653/#662 — mirrors Increment 3's StockPage structural guard: the width
- // cap in styles.css is keyed on `.toolbar input[type="date"]`, so the wrapper
- // is the only honest thing jsdom (no layout engine) can assert here.
- it("puts the date range in the bounded toolbar, not a bare filters row", async () => {
+ // #653/#662/#831 — mirrors StockPage's structural guard: the width cap
+ // moved from `.toolbar input[type="date"]` to FilterDateField's own `sx`,
+ // so the wrapper the field renders inside is the only honest thing jsdom
+ // (no layout engine) can assert here.
+ it("puts the date range in the bounded FilterBar, not a bare filters row", async () => {
renderWithProviders(, { token: ADMIN });
await waitFor(() => expect(mockListExpenses).toHaveBeenCalled());
const fromInput = screen.getByLabelText("From");
- expect(fromInput.closest("div.toolbar")).not.toBeNull();
+ expect(fromInput.closest(".MuiPaper-outlined")).not.toBeNull();
});
diff --git a/web/src/routes/ExpensesPage.tsx b/web/src/routes/ExpensesPage.tsx
index 10f80e7e..0b1c5761 100644
--- a/web/src/routes/ExpensesPage.tsx
+++ b/web/src/routes/ExpensesPage.tsx
@@ -3,6 +3,9 @@ import type { FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { FilterX, Receipt } from "lucide-react";
+import {
+ Box, DialogActions, Divider, List, ListItem, ListItemText, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography,
+} from "@mui/material";
import {
adjustExpense, createExpense, createExpenseCategory, getExpense,
listExpenseCategories, listExpenses, listFlocks, updateExpenseCategory,
@@ -14,6 +17,7 @@ import { FarmDate } from "../components/FarmDate";
import { BusyButton } from "../components/BusyButton";
import { Dialog } from "../components/Dialog";
import { EmptyState } from "../components/EmptyState";
+import { FilterBar, FilterDateField } from "../components/FilterBar";
import { FlockPicker } from "../components/FlockPicker";
import type { PickerSnapshot } from "../components/NamedEntityPicker";
import { DialogError } from "../components/DialogError";
@@ -30,6 +34,11 @@ function errText(err: unknown): string {
}
const PAGE = 100;
+const NOWRAP = { whiteSpace: "nowrap" as const };
+// #831 — replicates the retired `.form-grid .named-picker` rule: without a
+// fixed flex-basis the picker's closed (button) and open (input) states have
+// different intrinsic widths, which used to shift every sibling field.
+const PICKER_SX = { flex: "0 1 15rem", width: "15rem", minWidth: "8rem", maxWidth: "100%" };
// The scopes that own a dialog (#703). `run` routes a failure by this and gates
// a success by it; a scope outside the list — the record-expense form on the
@@ -544,38 +553,38 @@ export function ExpensesPage() {
return (
-
{t("title")}
-
-
- {/* #667 — a from/to pair matching every sibling list screen; the
- category filter beside it is not a date control and stays outside
- the toolbar. */}
-
- {/* No `max` on either bound. The month picker this replaced capped at
- the current MONTH, which contained its own month-end default; a
- day-granularity control capped at TODAY does not — the default
- `to` is month-end, so the cap made the input render a value it
- forbade, and made the default unreachable once changed. The
- sibling range filters (Feed, Water, History) ship uncapped for the
- same reason: a future window is empty by construction, which is
- cheaper than a control that argues with its own value. */}
-
-
-
-
+ {t("title")}
+
+ {/* #667/#831 — a from/to pair matching every sibling list screen, the
+ category filter beside it in the same bar now that FilterBar governs
+ the whole filter row rather than only the bounded dates. */}
+
+ {/* No `max` on either bound. The month picker this replaced capped at
+ the current MONTH, which contained its own month-end default; a
+ day-granularity control capped at TODAY does not — the default
+ `to` is month-end, so the cap made the input render a value it
+ forbade, and made the default unreachable once changed. The
+ sibling range filters (Feed, Water, History) ship uncapped for the
+ same reason: a future window is empty by construction, which is
+ cheaper than a control that argues with its own value. */}
+ setFrom(e.target.value)} />
+ setTo(e.target.value)} />
+ setFilterCategory(e.target.value)}
+ >
+
+ {categories.map((c) => (
+
+ ))}
+
@@ -587,7 +596,7 @@ export function ExpensesPage() {
{tc("clearFiltersButton")}
)}
-
+
{/* The total belongs to the rows below it: it lands and clears with
them, so it can never describe a period they do not (#469). It is
@@ -607,107 +616,150 @@ export function ExpensesPage() {
)}
{showCategories && (
-
+ // Pair 15 (#822 D2): the drill-down is a ruled region, not a card —
+ // a Box between two Dividers, an h3, no fill, no radius. Same shape
+ // #897 gave Flocks' ledger panel; Expenses was the last remaining
+ // `.order-panel` consumer besides Sales/Inventory (#831 converts all
+ // three in this slice).
+
+
+
+ {t("categoriesHeading")}
+
+
+
+
+
+
+ {/* Direction A: ruled rows, not bullets — a small ruled list
+ mirrors the table shape every other list on this screen uses. */}
+
+ {categories.map((c, i) => (
+ onToggleCategory(c)}>
+ {c.active ? t("deactivateButton") : t("reactivateButton")}
+
+ }
+ >
+
+
+ ))}
+ {categories.length === 0 && (
+
+
+
+ )}
+
+
+
+
)}
{t("recordExpenseHeading")}
-
+ {/* No known denomination means no recording: converting the typed
+ amount would have to guess the scale (#469 codex review).
+ #512 (T028): the picker's canSubmit gates the write too — an
+ exploring/uninitialized picker must not submit a stale flock. */}
+
+ {t("recordExpenseButton")}
+
+
{activeCategories.length === 0 && (
- {/* #493 — full audit trail for this record, distinct from
- the created/last-changed summary in ProvenanceCell. */}
-
- {tc("recordHistory.viewHistoryLink")}
-
- {/* Opens the correction dialog — non-mutating, so the
- spinner belongs to the dialog's Save, not here (#242). */}
-
-
-
- ))}
-
-
+
+
+
+
+ {t("dateHeader")}
+ {t("categoryHeader")}
+ {t("descriptionHeader")}
+ {t("amountHeader")}
+ {t("flockHeader")}
+ {t("noteHeader")}
+ {tc("recordHistoryHeader")}
+
+
+
+
+ {expenses.rows.map((x) => (
+
+
+ {categoryName(x.expenseCategoryId)}
+ {x.description}
+ {fmt.money(x.amountMinorUnits, x.currencyCode, x.currencyMinorUnit)}
+ {rowFlockName(x)}
+ {x.note ?? "—"}
+
+
+ {/* #493 — full audit trail for this record, distinct from
+ the created/last-changed summary in ProvenanceCell. */}
+
+ {tc("recordHistory.viewHistoryLink")}
+
+ {/* Opens the correction dialog — non-mutating, so the
+ spinner belongs to the dialog's Save, not here (#242). */}
+
+
+
+ ))}
+
+
;
+ if (error && usage.rows === null) return {t("title")}
{error}
;
+ if (usage.rows === null) return {t("title")}
{tc("loading")}
;
return (
-
{t("title")}
+ {t("title")}
{t("intro")}
-
+
{/* Feed is create-only — the FIFO stock draw already happened, so a
mis-entry is undone with a compensating lot adjustment, not an edit. */}
@@ -315,8 +343,8 @@ export function FeedPage() {
{/* List failures degrade the LIST only — the capture form must stay
usable through a transient history read failure (review of #446). */}
{usage.error &&
{usage.error}
}
-
-
+
+
}
/>
-
- {/* #653 — the date range gets its own bounded toolbar; the flock
- picker above stays a plain form-grid field. */}
-
-
-
-
-
+
+ setFrom(e.target.value)} />
+ setTo(e.target.value)} />
+
{/* One window's rows must never sit under another window's controls,
not even for the length of the request (#469). Only this region is
@@ -379,23 +399,32 @@ export function FeedPage() {
:
) : (
<>
-
+
{usage.canLoadMore && (
// Two rapid clicks cannot append the same page twice: the hook
// no-ops a load-more while one is in flight, and canLoadMore
diff --git a/web/src/routes/HistoryPage.test.tsx b/web/src/routes/HistoryPage.test.tsx
index 73e7a88a..20350315 100644
--- a/web/src/routes/HistoryPage.test.tsx
+++ b/web/src/routes/HistoryPage.test.tsx
@@ -332,12 +332,22 @@ describe("HistoryPage adjust — reconciliation guard", () => {
// shows and what it allows would fail here.
describe("HistoryPage adjust — mirrored daily-entry layout", () => {
const dialog = () => screen.getByRole("dialog");
- // Class-selected, exactly as DailyEntryPage.test.tsx selects the same two
- // readouts: neither has an unambiguous role here either — every BusyButton
- // renders its own sr-only role="status" for the "Working…" announcement, so
- // the chip's live region is one of several.
+ // `.entry-chip` is GradingChip's own class (component untouched by #831,
+ // shared with DailyEntryPage) — still class-selected for the same reason
+ // DailyEntryPage.test.tsx gives: every BusyButton renders its own sr-only
+ // role="status" for the "Working…" announcement, so the chip's live region
+ // is one of several and a role alone would not disambiguate it.
const chip = () => dialog().querySelector(".entry-chip") as HTMLElement;
- const sellableReadout = () => dialog().querySelector(".entry-readout") as HTMLElement;
+ // #831 dropped `.entry-readout` in favor of the same role-scoped lookup
+ // DailyEntryPage.test.tsx uses for its converted counterpart: `role="alert"`
+ // once losses exceed the total, `role="status"` in the normal case, both
+ // scoped to the Egg counts section so they cannot match the chip's status.
+ const countsSection = () =>
+ within(dialog()).getByRole("heading", { name: /Egg counts/ }).closest("section") as HTMLElement;
+ const sellableReadout = () => {
+ const section = countsSection();
+ return within(section).queryByRole("alert") ?? within(section).getByRole("status");
+ };
it("shows both steps and the sellable figure the grading pane has to hit", async () => {
mockListDailyEntries.mockResolvedValue([SUBMITTED]);
@@ -431,7 +441,9 @@ describe("HistoryPage adjust — mirrored daily-entry layout", () => {
await openAdjustPanel();
fireEvent.click(within(dialog()).getByRole("button", { name: /remaining 30/ }));
- const gradeBRow = screen.getByRole("spinbutton", { name: "Grade B" }).closest(".entry-row")!;
+ // #831: the row is now a named `role="group"` (the F134 drop target),
+ // exactly as DailyEntryPage.test.tsx selects its own converted rows.
+ const gradeBRow = within(dialog()).getByRole("group", { name: "Grade B row" });
// A foreign drag (plain text — what dropping a link or a selection looks
// like) must leave the line untouched.
diff --git a/web/src/routes/HistoryPage.tsx b/web/src/routes/HistoryPage.tsx
index b43e03b8..2f966501 100644
--- a/web/src/routes/HistoryPage.tsx
+++ b/web/src/routes/HistoryPage.tsx
@@ -3,6 +3,9 @@ import type { FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { FilterX, Inbox } from "lucide-react";
+import {
+ Box, DialogActions, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography,
+} from "@mui/material";
import {
adjustDailyEntry, getDailyEntry, listDailyEntries, listEggGrades, listEggUnitConversions,
listFlocks, voidDailyEntry,
@@ -15,6 +18,8 @@ import { useAuth } from "../auth/useAuth";
import { BusyButton } from "../components/BusyButton";
import { Dialog } from "../components/Dialog";
import { EmptyState } from "../components/EmptyState";
+import { EntryRow } from "../components/EntryRow";
+import { FilterBar, FilterDateField } from "../components/FilterBar";
import { FlockPicker } from "../components/FlockPicker";
import { DialogError } from "../components/DialogError";
import { GradingChip, TakeRemainderButton, remainderDropProps } from "../components/GradingChip";
@@ -33,6 +38,11 @@ import { useMe } from "../session/SessionContext";
import i18n from "../i18n";
const PAGE = 50;
+const NOWRAP = { whiteSpace: "nowrap" as const };
+// #831 — replicates the retired `.form-grid .named-picker` rule: without a
+// fixed flex-basis the picker's closed (button) and open (input) states have
+// different intrinsic widths, which used to shift every sibling field.
+const PICKER_SX = { flex: "0 1 15rem", width: "15rem", minWidth: "8rem", maxWidth: "100%" };
// The scope that owns a dialog (#703). `run` routes a failure by this and gates
// a success by it; `void:` from the row button reports to the page and is
@@ -526,19 +536,19 @@ export function HistoryPage() {
// fatal case: without those, every row renders unresolvable ids. `entries`
// is the hook's handle, so the emptiness test is on its rows.
if (errors.page && entries.rows === null)
- return
{t("loadingTitle")}
{errors.page}
;
+ return {t("loadingTitle")}
{errors.page}
;
return (
-
{t("title")}
+ {t("title")}
{isAdmin && (
{t("intro")}
)}
-
-
+
+
{/* #512 (T038) — the read-only filter became an optional
eligibility=all FlockPicker: the filter keeps its exact id
ownership (the list fetches by `flockFilter`), and a row-owned
@@ -571,18 +581,10 @@ export function HistoryPage() {
}
/>
-
- {/* #653 — the date range gets its own bounded toolbar; the flock
- picker above stays a plain form-grid field. */}
-
-
+
+
{/* The word boundaries live in the h3's own text nodes, not at
the edges of the sr-only span: accessible-name computation
- trims each nested element's contribution. */}
-
assignRest(g.id))}>
- {g.name}{g.active ? "" : t("inactiveGradeSuffix")}
- {/* #443 — no max=: same as the capture screen, the old
- ceiling refused to let a grade run ahead of the
- total. setLine raises the total to fit instead. */}
-
- {armed && (
- assignRest(g.id)} />
- )}
-
- ))}
-
-
- {/* The same chip the capture screen uses — here it is also
- exactly what the Save button is gated on (#394). */}
-
-
-
-
-
- {t("reasonLabel")}
- setReason(e.target.value)} />
-
+ trims each nested element's contribution. This dialog keeps
+ the visible `.step-n` pill DailyEntryPage's own heading made
+ sr-only (#830) — its comment names this screen as the
+ reason that CSS rule stays declared. */}
+
+ {te("stepLabel", { n: 1 })}{te("stepOfTotal")} {te("eggCountsHeading")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* NO step — deaths are birds, not eggs; see the capture
+ screen's identical comment (codex P1 review of #451). */}
+
+
+
+
+ {lossesExceedTotal ? (
+
+ {te("countsExceedTotalMessage", { losses: grading.losses, total })}
+
+ ) : (
+ /* Shown as a value, not buried in a sentence — it is the
+ target the grading pane has to hit. */
+
+ {te("sellableLabel")} {te("sellableFormula", { total, cracked, dirty, discarded })}
+ {sellable}
+
+ )}
+
+
+
+
+ {te("stepLabel", { n: 2 })}{te("stepOfTotal")} {te("gradingHeading")}
+
+ {panelGrades(adjusting).map((g) => (
+ assignRest(g.id))}
+ >
+ {/* #443 — no max=: same as the capture screen, the old
+ ceiling refused to let a grade run ahead of the
+ total. setLine raises the total to fit instead. */}
+
+ {armed && (
+ assignRest(g.id)} />
+ )}
+
+ ))}
+
+ {/* The same chip the capture screen uses — here it is also
+ exactly what the Save button is gated on (#394). */}
+
+
+
+
+ setReason(e.target.value)}
+ />
{/* The 409 rebind reports here, beside the form it asks you to re-apply. */}
-
+
{/* #394: an adjustment has no draft state — Save stays disabled
until grading reconciles exactly, the same rule Daily
Entry's submit uses. */}
{t("saveAdjustmentButton")}
-
- {/* #396 — Losses shows the cracked/dirty/discarded COUNTS
- whatever became of them; this shows how many of those
- actually became stock, per the entry's own snapshot. */}
-
- {/* #493 — full audit trail for this record, distinct from
- the created/last-changed summary in ProvenanceCell.
- Admin-gated: /api/v1/audit is AdminOnly, and this
- screen is open to workers too (codex review of
- #516). */}
- {isAdmin && (
-
- {tc("recordHistory.viewHistoryLink")}
-
- )}
- {/* Drafts are edited on the Daily entry screen (#85) —
- open to workers too; adjust/void stay admin-only. */}
- {e.status === "Draft" && flockEditable(e) && (
-
- {t("editButton")}
-
- )}
- {isAdmin && correctable(e) && (
- <>
- {/* Opens the dialog — the mutation's own trigger (and
- its spinner) is the dialog's Save adjustment. */}
-
- void onVoid(e)}>{t("voidButton")}
- >
- )}
-
-
- ))}
-
-
+
+
+
+
+ {t("dateHeader")}
+ {t("flockHeader")}
+ {t("statusHeader")}
+ {t("totalHeader")}
+ {t("lossesHeader")}
+ {/* #396 — Losses shows the cracked/dirty/discarded COUNTS
+ whatever became of them; this shows how many of those
+ actually became stock, per the entry's own snapshot. */}
+ {t("conditionHeader")}
+ {t("mortalityHeader")}
+ {t("gradedHeader")}
+ {tc("recordHistoryHeader")}
+
+
+
+
+ {entries.rows.map((e) => (
+
+
+ {rowFlockName(e)}
+ {statusCell(e)}
+ {fmt.count(e.totalEggs)}
+ {fmt.count(e.crackedEggs)}/{fmt.count(e.dirtyEggs)}/{fmt.count(e.discardedEggs)}
+ {conditionStock(e)}
+ {fmt.count(e.mortalityCount)}
+
+ {e.grades.length === 0
+ ? "—"
+ : e.grades.map((g) => `${gradeName(g.eggGradeId)} ${fmt.count(g.quantity)}`).join(", ")}
+
+
+
+ {/* #493 — full audit trail for this record, distinct from
+ the created/last-changed summary in ProvenanceCell.
+ Admin-gated: /api/v1/audit is AdminOnly, and this
+ screen is open to workers too (codex review of
+ #516). */}
+ {isAdmin && (
+
+ {tc("recordHistory.viewHistoryLink")}
+
+ )}
+ {/* Drafts are edited on the Daily entry screen (#85) —
+ open to workers too; adjust/void stay admin-only. */}
+ {e.status === "Draft" && flockEditable(e) && (
+
+ {t("editButton")}
+
+ )}
+ {isAdmin && correctable(e) && (
+ <>
+ {/* Opens the dialog — the mutation's own trigger (and
+ its spinner) is the dialog's Save adjustment. */}
+
+ void onVoid(e)}>{t("voidButton")}
+ >
+ )}
+
+
+ ))}
+
+