From c5cdf4f3ae233d69ac25510627ac2d4e5ec4295d Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 19 Sep 2026 07:54:45 +0000 Subject: [PATCH 1/5] feat: redesign daily entry as count workbench --- .../daily-entry-direction-lab.html | 105 +++++++ web/src/i18n/en.ts | 15 +- web/src/i18n/es.ts | 15 +- web/src/i18n/tl.ts | 15 +- web/src/routes/DailyEntryPage.test.tsx | 40 ++- web/src/routes/DailyEntryPage.tsx | 292 ++++++++++-------- 6 files changed, 342 insertions(+), 140 deletions(-) create mode 100644 docs/designs/674-daily-entry-redesign/daily-entry-direction-lab.html diff --git a/docs/designs/674-daily-entry-redesign/daily-entry-direction-lab.html b/docs/designs/674-daily-entry-redesign/daily-entry-direction-lab.html new file mode 100644 index 000000000..2876bb357 --- /dev/null +++ b/docs/designs/674-daily-entry-redesign/daily-entry-direction-lab.html @@ -0,0 +1,105 @@ + + + + + +Cluckwork Daily Entry Direction Lab + + + + +
Daily Entry direction labOperate surface · same workflow and real counts · current navigation preserved
+
+ +
+ +
Meadowlark Farm

Daily entry

Draft saved at 12:43
16 September 2026House 1 layers
Review before submittingDraft not submitted · Mortality will reduce the flock to 419 hens
House 1 layers (ISA Brown)⌄
16 Sep 2026▣
+

Egg counts

Count first
Total eggsAll eggs collected today
Cracked
Dirty
Discarded
MortalityBirds, not eggs
Sellable
430 − 4 − 2 − 1
423
+

Grading

423 counted
Small
Medium
Large
Jumbo
Seconds
Grading complete423
+ +
Meadowlark Farm · Morning round

Count and grade today’s eggs

House 1 layers · Draft saved at 12:43
Wednesday16 September 2026
Before submission1 mortality will reduce the flock from 420 to 419 hens.
House 1 layers (ISA Brown)⌄
16 Sep 2026▣
+

1 · Count the collection

Eggs and losses
Total eggsThe whole collection before losses
Cracked
Dirty
Discarded
MortalityFlock event · counted in birds
Non-sellable eggs7
Sellable target423
+

2 · Grade the sellable eggs

Target 423
Small
Medium
Large
Jumbo
Seconds
Graded423
Still to grade0
Ready to submit423 of 423
+ +
Meadowlark Farm · Field sheet

Daily collection

House 1 layers · Draft saved at 12:43
16 September 2026Morning round
One flock changeSubmitting records 1 mortality and leaves 419 hens.
House 1 layers (ISA Brown)⌄
16 Sep 2026▣
+
Stage 1Count
Total eggsWhole collection
Cracked
Dirty
Discarded
MortalityBirds
Sellable423
→
Stage 2Grade
Small
Medium
Large
Jumbo
Seconds
Balanced423
Total eggs430
Losses7
Sellable423
Graded423
+
+ +
+ + + diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2a2fb981c..5fde679f4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -629,7 +629,7 @@ export const en = { // (the entry-locked banner's status word) goes through the `enums` // `statusLabel` helper, not a key here — see DailyEntryPage.tsx. dailyEntry: { - title: "Daily entry", + title: "Count and grade today's eggs", // Imperative messages (event handlers / promise callbacks — see // CONTRIBUTING-i18n.md's imperative i18n.t() pattern). @@ -676,6 +676,10 @@ export const en = { // the grading pane's gradesSum read the other way from GradingChip's own // count-DOWN wording. gradedCountCaption: "Counted {{graded}}", + collectionHeading: "1 · Count the collection", + collectionCaption: "Eggs and losses", + gradingWorkbenchHeading: "2 · Grade the sellable eggs", + gradingTargetCaption: "Target {{target}}", // #830 — the attention line under the title (mockup: daily-entry.html // `.attn`). i18next selects the plural form on {{count}} (same pattern @@ -703,12 +707,21 @@ export const en = { dirtyLabel: "Dirty", discardedLabel: "Discarded", mortalityLabel: "Mortality", + totalEggsCaption: "All eggs collected today", + mortalityEventCaption: "Separate flock event · birds", // Reconciliation readouts (counts pane) countsExceedTotalMessage: "Cracked + dirty + discarded ({{losses}}) exceed total eggs ({{total}}).", sellableLabel: "Sellable", sellableFormula: "{{total}} − {{cracked}} − {{dirty}} − {{discarded}}", + collectionTotalsLabel: "Collection totals", + gradingTotalsLabel: "Grading totals", + nonSellableLabel: "Non-sellable eggs", + sellableTargetLabel: "Sellable target", + gradedLabel: "Graded", + stillToGradeLabel: "Still to grade", + gradingProgress: "{{graded}} of {{target}}", deactivatedGradeSuffix: " (deactivated)", // F134 remainder-assignment gesture (grading pane) diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 070b1fb65..6e12657eb 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -470,7 +470,7 @@ export const es = { // The entry-locked banner's status word goes through the `enums` // statusLabel helper, not a key here. dailyEntry: { - title: "Registro diario", + title: "Cuente y clasifique los huevos de hoy", // Imperative messages loadFlocksGradesFailed: "No se pudieron cargar los lotes/grados. ¿Está la API activa?", @@ -512,6 +512,10 @@ export const es = { eggCountsHeading: "Conteo de huevos", gradingHeading: "Clasificación", gradedCountCaption: "Contados {{graded}}", + collectionHeading: "1 · Cuente la recolección", + collectionCaption: "Huevos y pérdidas", + gradingWorkbenchHeading: "2 · Clasifique los huevos vendibles", + gradingTargetCaption: "Objetivo {{target}}", attentionHeading_one: "{{count}} cosa.", attentionHeading_other: "{{count}} cosas.", @@ -525,12 +529,21 @@ export const es = { dirtyLabel: "Sucios", discardedLabel: "Descartados", mortalityLabel: "Mortalidad", + totalEggsCaption: "Todos los huevos recogidos hoy", + mortalityEventCaption: "Evento separado del lote · aves", // Reconciliation readouts (counts pane) countsExceedTotalMessage: "Rotos + sucios + descartados ({{losses}}) superan el total de huevos ({{total}}).", sellableLabel: "Vendibles", sellableFormula: "{{total}} − {{cracked}} − {{dirty}} − {{discarded}}", + collectionTotalsLabel: "Totales de recolección", + gradingTotalsLabel: "Totales de clasificación", + nonSellableLabel: "Huevos no vendibles", + sellableTargetLabel: "Objetivo vendible", + gradedLabel: "Clasificados", + stillToGradeLabel: "Faltan por clasificar", + gradingProgress: "{{graded}} de {{target}}", deactivatedGradeSuffix: " (desactivado)", // Remainder-assignment gesture (grading pane) diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 03f9dd53c..c64a00f38 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -490,7 +490,7 @@ export const tl = { // The entry-locked banner's status word goes through the `enums` // statusLabel helper, not a key here. dailyEntry: { - title: "Araw-araw na Tala", + title: "Bilangin at i-grade ang mga itlog ngayong araw", // Imperative messages loadFlocksGradesFailed: "Hindi na-load ang mga kawan/grado. Gumagana ba ang API?", @@ -533,6 +533,10 @@ export const tl = { eggCountsHeading: "Bilang ng Itlog", gradingHeading: "Pag-grade", gradedCountCaption: "Binilang {{graded}}", + collectionHeading: "1 · Bilangin ang nakolekta", + collectionCaption: "Mga itlog at nawala", + gradingWorkbenchHeading: "2 · I-grade ang mga itlog na nabebenta", + gradingTargetCaption: "Target {{target}}", attentionHeading_one: "{{count}} bagay.", attentionHeading_other: "{{count}} bagay.", @@ -546,6 +550,8 @@ export const tl = { dirtyLabel: "Madumi", discardedLabel: "Itinapon", mortalityLabel: "Pagkamatay", + totalEggsCaption: "Lahat ng itlog na nakolekta ngayon", + mortalityEventCaption: "Hiwalay na pangyayari sa kawan · mga ibon", // Reconciliation readouts (counts pane) countsExceedTotalMessage: @@ -553,6 +559,13 @@ export const tl = { + "itlog ({{total}}).", sellableLabel: "Nabebenta", sellableFormula: "{{total}} − {{cracked}} − {{dirty}} − {{discarded}}", + collectionTotalsLabel: "Kabuuan ng nakolekta", + gradingTotalsLabel: "Kabuuan ng pag-grade", + nonSellableLabel: "Mga itlog na hindi nabebenta", + sellableTargetLabel: "Target na nabebenta", + gradedLabel: "Na-grade", + stillToGradeLabel: "Iga-grade pa", + gradingProgress: "{{graded}} sa {{target}}", deactivatedGradeSuffix: " (naka-deactivate)", // Remainder-assignment gesture (grading pane) diff --git a/web/src/routes/DailyEntryPage.test.tsx b/web/src/routes/DailyEntryPage.test.tsx index 29e6793ec..1cc099e67 100644 --- a/web/src/routes/DailyEntryPage.test.tsx +++ b/web/src/routes/DailyEntryPage.test.tsx @@ -131,7 +131,7 @@ function submitBtn() { // (component untouched by #830, still shared with HistoryPage) and needs no // change at all. function countsSection() { - return screen.getByRole("heading", { name: /Egg counts/ }).closest("section") as HTMLElement; + return screen.getByRole("heading", { name: /Count the collection/ }).closest("section") as HTMLElement; } function sellableReadout() { const section = countsSection(); @@ -170,7 +170,7 @@ describe("DailyEntryPage grading pane excludes counter-fed grades", () => { // on this screen: Egg counts has a Cracked *counter*, which must stay — an // unscoped query matches that and passes whatever the Grading pane does. // (The first version of this test did exactly that and had to be tightened.) - const grading = screen.getByRole("heading", { name: /Grading/ }) + const grading = screen.getByRole("heading", { name: /Grade the sellable eggs/ }) .closest("section") as HTMLElement; expect(within(grading).getByLabelText("Grade A")).toBeInTheDocument(); @@ -178,7 +178,7 @@ describe("DailyEntryPage grading pane excludes counter-fed grades", () => { expect(within(grading).queryByLabelText("Cracked")).not.toBeInTheDocument(); // ...while the counter it IS fed by stays where it belongs. - const counts = screen.getByRole("heading", { name: /Egg counts/ }) + const counts = screen.getByRole("heading", { name: /Count the collection/ }) .closest("section") as HTMLElement; expect(within(counts).getByLabelText("Cracked")).toBeInTheDocument(); }); @@ -592,21 +592,33 @@ describe("DailyEntryPage submit confirmation", () => { // numbered steps in the order the work actually happens, with the // reconciliation line and both saves pinned in a footer. describe("DailyEntryPage structure", () => { - it("labels the three steps without speaking the numerals twice", async () => { + it("names the two workbench steps in the order the collection is counted", async () => { await renderReady(); - // Two steps, not three: choosing a flock and a date says WHICH day is being - // recorded, it is not part of recording it. #830 dropped the visible - // "Step n" pill from this page's own headings (the mockup shows a plain - // "Egg counts"/"Grading" heading; `.step-n`'s CSS and its visible pill - // stay for HistoryPage's mirror of this layout) — "Step n of 2" is now - // entirely sr-only, but accessible-name computation does not care whether - // a contributing span is visually clipped, so the full name is unchanged. - expect(screen.getByRole("heading", { name: "Step 1 of 2: Egg counts" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Step 2 of 2: Grading" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "1 · Count the collection" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "2 · Grade the sellable eggs" })).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: /Flock/ })).toBeNull(); }); + it("shows collection and grading totals without requiring subtraction", async () => { + await renderReady(); + setNum("Total eggs", 100); + setNum("Cracked", 2); + setNum("Dirty", 3); + setNum("Discarded", 5); + setNum("Grade A", 60); + setNum("Grade B", 25); + + const collection = screen.getByRole("group", { name: "Collection totals" }); + expect(collection).toHaveTextContent("Non-sellable eggs10"); + expect(collection).toHaveTextContent("Sellable target90"); + + const grading = screen.getByRole("group", { name: "Grading totals" }); + expect(grading).toHaveTextContent("Graded85"); + expect(grading).toHaveTextContent("Still to grade5"); + expect(grading).toHaveTextContent("85 of 90"); + }); + it("puts each readout with the fields it describes, and the saves in the footer", async () => { await renderReady(); @@ -615,7 +627,7 @@ describe("DailyEntryPage structure", () => { // was the whole complaint. #830: pane-scoped by heading instead of // `.entry-pane` (the class stays for HistoryPage, but #830's own markup no // longer carries it). - const gradingSection = screen.getByRole("heading", { name: /Grading/ }).closest("section") as HTMLElement; + const gradingSection = screen.getByRole("heading", { name: /Grade the sellable eggs/ }).closest("section") as HTMLElement; expect(within(countsSection()).getByRole("status")).toBeInTheDocument(); expect(gradingSection.querySelector(".entry-chip")).not.toBeNull(); diff --git a/web/src/routes/DailyEntryPage.tsx b/web/src/routes/DailyEntryPage.tsx index b5b60e668..6156b5647 100644 --- a/web/src/routes/DailyEntryPage.tsx +++ b/web/src/routes/DailyEntryPage.tsx @@ -80,13 +80,14 @@ const STEPPER_SX = { // "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, label, caption, groupLabel, armed = false, variant = "standard", dropProps, children, }: { htmlFor: string; label: string; caption?: string; groupLabel?: string; armed?: boolean; + variant?: "standard" | "primary" | "flock-event"; dropProps?: ReturnType; children: ReactNode; }) { @@ -98,8 +99,18 @@ function EntryRow({ sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) auto", alignItems: "center", - gap: 2, minHeight: { xs: 52, md: 44 }, py: 1, + gap: 2, minHeight: { xs: 64, md: 52 }, px: 2, py: 1, borderBottom: "1px solid var(--rule)", + ...(variant === "primary" ? { + minHeight: { xs: 76, md: 68 }, + backgroundColor: "var(--tint-accent)", + "& .numfield-step": { width: { xs: 48, md: 42 }, height: { xs: 48, md: 42 } }, + } : {}), + ...(variant === "flock-event" ? { + mt: 1, + background: "linear-gradient(90deg, var(--tint-warn), transparent)", + borderTop: "1px dashed var(--warn)", + } : {}), ...(armed ? { outline: "1px dashed var(--stat-accent)", outlineOffset: "4px", borderRadius: "var(--r-input)", @@ -107,14 +118,17 @@ function EntryRow({ ...STEPPER_SX, }} > - - {label} + + + + {label} + + {caption && ( + + + {title} + + + {caption} + + + {children} + {summary} + + ); +} + +function SummaryStat({ label, value, live = false }: { label: string; value: string; live?: boolean }) { + return ( + + + {label} + + + {value} + + + ); +} + // Capture targets active flocks plus depleted ones — a depleted flock still // accepts backfilled entries up to its depletion date (the API gates exact @@ -748,11 +810,15 @@ export function DailyEntryPage() { own RecordHistory timestamp is available (every real record has one; #819), else the bare "Editing draft" fallback a pre-#494 row with no history still gets. */} - + + {selectedFlock && {selectedFlock.name}} + {selectedFlock && editingDraft && ·} {editingDraft && ( - savedTime !== null - ? t("dashboard:entryStateDraftTime", { time: savedTime }) - : t("editingDraftBadge") + + {savedTime !== null + ? t("dashboard:entryStateDraftTime", { time: savedTime }) + : t("editingDraftBadge")} + )} @@ -763,8 +829,10 @@ export function DailyEntryPage() { precedent this mirrors. */} {attentionShown.length > 0 && ( @@ -796,8 +864,9 @@ export function DailyEntryPage() { did; that is the frame the owner approved. */} @@ -955,28 +1024,37 @@ export function DailyEntryPage() { ); })()} - {/* Side by side, because the two panes reconcile: the sellable figure the - left one produces is the target the right one has to hit. Reading one - while the other was a screen away was the whole problem. #830: one - full-width column at phone width (D3.3 amended below), a 2-up Grid - from md up. */} + {/* The two work cards stay side by side when space allows because the + collection result is the grading target. */} - - {/* The visible heading is just "Egg counts" (mockup: no numbered - step badge); "Step 1 of 2:" stays exactly as spoken before, fully - sr-only now instead of half-visible via `.step-n` — the - accessible NAME string is unchanged (accessible-name computation - does not care whether a contributing span is clipped or not), so - `getByRole("heading", { name: "Step 1 of 2: Egg counts" })` still - matches. `.step-n`'s own CSS stays: HistoryPage's adjust dialog - still renders the visible pill on its mirror of this heading. */} - - - {t("stepLabel", { n: 1 })} {t("stepOfTotal")} {t("eggCountsHeading")} - - - - + + + + {lossesExceedTotal && ( + + {t("countsExceedTotalMessage", { losses, total: totalEggs })} + + )} + + )} + > + @@ -992,50 +1070,68 @@ export function DailyEntryPage() { - {/* NO step: the pack unit counts EGGS. One tap here records a dead - BIRD, and submitting writes the bird-ledger movement — a Tray - farm must never log 30 deaths per tap (codex P1 review of #451). */} - + + - {lossesExceedTotal ? ( - - {t("countsExceedTotalMessage", { losses, total: totalEggs })} - - ) : ( - /* Shown as a value, not buried in a sentence — it is the target - the grading pane has to hit. */ - - {t("sellableLabel")}
{t("sellableFormula", { total: totalEggs, cracked, dirty, discarded })}
- {sellable} -
+ + + + + + span::before": { + content: '""', display: "inline-block", width: 8, height: 8, + borderRadius: "50%", marginRight: "6px", verticalAlign: "middle", + background: "var(--warn)", + }, + "& .entry-chip.done .entry-chip-text > span::before": { background: "var(--success)" }, + }}> + + + {t("gradingProgress", { + graded: fmt.count(gradesSum), + target: lossesExceedTotal ? "—" : fmt.count(sellable), + })} + + +
)} -
- - - - - {t("stepLabel", { n: 2 })} {t("stepOfTotal")} {t("gradingHeading")} - - {/* Mockup: "Counted 298" — the same sum GradingChip counts DOWN - from, read the other way. */} - - {t("gradedCountCaption", { graded: fmt.count(gradesSum) })} - - - + > {visibleGrades.map((g) => ( assignRest(g.id))} > - {/* #443 — no max=: the old ceiling refused to let a grade run - ahead of step 1's total, forcing the total to be known - before grading could finish. setGrade now raises the total - to fit instead. */} @@ -1045,57 +1141,7 @@ export function DailyEntryPage() { )} ))} - - {/* The count changes as they type, and it is the only feedback that - the day adds up — see GradingChip for its live-region shape. - #830 (owner's screenshot review of #888) — GradingChip.tsx is - untouched (shared with HistoryPage's adjust dialog, which mirrors - this layout and keeps its own pill); these descendant selectors - restyle only THIS page's chip into the mockup's `.row.total` — - a ruled row, no fill (DIRECTION.md forbids a badge fill for - status), the count right-aligned and the "says" wording left - beside a status dot. `.entry-chip-text`'s DOM order stays - count-then-says (`toHaveTextContent("90 graded — the day adds - up")` reads left-to-right in DOM order, not screen order), so - the swap is `row-reverse` + `space-between`, never a JSX - reorder — that would read the text back to front. The dot is a - generated `::before` on the "says" span, contributing nothing - to its text content. - - `!important` on `background`/`border-radius`/`padding`: - FarmThemeProvider.tsx `prepend`s Emotion's `