From 33888be5940a1445e16a294fa60f8089d422cdb0 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 12 Sep 2026 17:51:03 +0000 Subject: [PATCH 1/8] fix(dashboard): tell a day with no entry from a day that laid no eggs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every figure on a production day defaulted to 0, so a day nobody recorded and a day the farm submitted with no eggs arrived identical to every consumer. The Dashboard's 14-day strip drew both as an empty slot, which asserted a zero it had no evidence for. `ProductionDay.EntryCount` is the count of OFFICIAL entries the day holds — one `g.Count()` on the aggregate that already runs. No new query, no index, no migration, and `GetProductionAsync` has one caller, so no export gains a column. On the strip: a recorded day keeps a stub at the baseline whatever it produced, an unrecorded day draws nothing, and `Avg` is the mean of the recorded days only. Min, peak, average and the latest figure are all over the recorded days and are null rather than 0 when there are none. The slot type becomes a discriminated union, so an unrecorded day can no longer carry a height, and the renderer narrows on `kind` with an exhaustiveness check rather than reading a flag. Each day is also a button carrying its own readout — the tooltip docks in a reserved row above the strip so it covers neither the bars nor the figures they are compared against, and its arrow is anchored to the slot element rather than positioned by index arithmetic, which drifts off the bar once the 4px gaps and the 9px week break are counted. Help prose said a Draft day "reads as zero"; it now reads as No entry, in all three locales. --- .../Features/Reports/IReportQueries.cs | 8 +- .../Repositories/ReportQueries.cs | 15 ++- .../ReportsTests.cs | 62 ++++++++- web/src/api/cluckwork.ts | 8 ++ web/src/components/DayStrip.test.tsx | 113 ++++++++++++----- web/src/components/DayStrip.tsx | 119 ++++++++++++++---- web/src/i18n/en.ts | 32 +++-- web/src/i18n/es.ts | 22 ++-- web/src/i18n/tl.ts | 21 +++- web/src/lib/dashboard.test.ts | 87 +++++++++---- web/src/lib/dashboard.ts | 103 ++++++++++----- web/src/routes/Dashboard.test.tsx | 65 ++++++++-- web/src/routes/Dashboard.tsx | 31 +++-- web/src/routes/HelpPage.test.tsx | 6 +- web/src/routes/ReportsPage.test.tsx | 4 +- web/src/styles.css | 111 +++++++++++++++- web/src/styles.test.ts | 5 +- 17 files changed, 650 insertions(+), 162 deletions(-) diff --git a/src/Cluckwork.Application/Features/Reports/IReportQueries.cs b/src/Cluckwork.Application/Features/Reports/IReportQueries.cs index 6400ad342..286e48c12 100644 --- a/src/Cluckwork.Application/Features/Reports/IReportQueries.cs +++ b/src/Cluckwork.Application/Features/Reports/IReportQueries.cs @@ -25,9 +25,15 @@ public interface IReportQueries // many did we grade" vs "how many can we sell"), they were only ever equal // because conditions used to be losses, and merging them would silently move // the number the capture screen is validated against. +// `EntryCount` is the number of OFFICIAL entries the day has (#780). Every other +// figure here is 0 both for a day nobody recorded and for a day that genuinely +// produced no eggs, so without it the two are indistinguishable to any consumer +// — which is what left the Dashboard's 14-day strip asserting a zero it had no +// evidence for. Zero means unrecorded; it does not mean the farm produced +// nothing. public sealed record ProductionDay( DateOnly Date, int TotalEggs, int Cracked, int Dirty, int Discarded, - int Sellable, int FromCounts, int Deaths, long HenDays, decimal? HenDayPct); + int Sellable, int FromCounts, int Deaths, int EntryCount, long HenDays, decimal? HenDayPct); public sealed record GradeTotal(Guid EggGradeId, string Name, int Quantity); diff --git a/src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs b/src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs index 20b8033f1..a699ea735 100644 --- a/src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs +++ b/src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs @@ -34,6 +34,19 @@ public async Task GetProductionAsync( (e.CrackedGradeId != null ? e.CrackedEggs : 0) + (e.DirtyGradeId != null ? e.DirtyEggs : 0)), Deaths = g.Sum(e => e.MortalityCount), + // #780 — how many entries the day HAS, so a consumer can tell a + // day nobody recorded from one that genuinely produced zero + // eggs. Every other figure here defaults to 0 for both, which + // made them arrive identical and left the Dashboard's day strip + // asserting production it had no evidence for. + // + // These are OFFICIAL entries (the Where above): Submitted, + // Locked, ManagerAdjusted. Deliberately stricter than the + // Dashboard's own `entryFor`, which counts a Draft — a report + // figure should rest on the same rows every other figure in + // this row rests on, so a day holding only a Draft reads as + // unrecorded here and as captured on the capture tiles. + EntryCount = g.Count(), }) .ToDictionaryAsync(x => x.Date, ct); @@ -172,7 +185,7 @@ public async Task GetProductionAsync( var sellable = total - (row?.Cracked ?? 0) - (row?.Dirty ?? 0) - (row?.Discarded ?? 0); days.Add(new ProductionDay( d, total, row?.Cracked ?? 0, row?.Dirty ?? 0, row?.Discarded ?? 0, - sellable, row?.FromCounts ?? 0, row?.Deaths ?? 0, henDays, + sellable, row?.FromCounts ?? 0, row?.Deaths ?? 0, row?.EntryCount ?? 0, henDays, henDays > 0 ? Math.Round(total * 100m / henDays, 1) : null)); if (d == DateOnly.MaxValue) break; // AddDays would overflow } diff --git a/tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs b/tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs index 2c2544919..8aa88c8ae 100644 --- a/tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs @@ -12,7 +12,7 @@ public sealed class ReportsTests(CluckworkWebApplicationFactory factory) private sealed record Created(Guid Id); private sealed record DayRow( DateOnly Date, int TotalEggs, int Cracked, int Dirty, int Discarded, - int Sellable, int FromCounts, int Deaths, long HenDays, decimal? HenDayPct); + int Sellable, int FromCounts, int Deaths, int EntryCount, long HenDays, decimal? HenDayPct); private sealed record GradeRow(Guid EggGradeId, string Name, int Quantity); private sealed record ProductionDto( List Days, int TotalEggs, int TotalSellable, int TotalFromCounts, @@ -174,6 +174,66 @@ async Task RecordAsync(DateOnly date, int total, int mortality, bool submi Assert.Equal(77 + 87, report.GradeTotals.Single().Quantity); } + // #780 — every figure on a production day defaults to 0, so a day nobody + // recorded and a day that genuinely produced no eggs arrived identical to + // every consumer. `EntryCount` is the only field that separates them, and + // it counts OFFICIAL entries, so a day holding only a Draft is unrecorded + // here even though the capture screen shows it as captured. + [Fact] + public async Task Production_EntryCount_SeparatesAnUnrecordedDayFromAZeroEggDay() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var farmId = Guid.NewGuid(); + var grades = await factory.SeedEggGradesAsync(accountId, farmId, "Large"); + var flockId = await factory.SeedFlockAsync(accountId, farmId); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + + async Task RecordAsync(DateOnly date, int total, bool submit) + { + var response = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, + houseId = Guid.NewGuid(), + flockId, + date, + totalEggs = total, + crackedEggs = 0, + dirtyEggs = 0, + discardedEggs = 0, + mortalityCount = 0, + grades = total == 0 + ? Array.Empty() + : [new { eggGradeId = grades["Large"], quantity = total }] + }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var id = (await response.Content.ReadFromJsonAsync())!.Id; + if (submit) + Assert.Equal(HttpStatusCode.OK, (await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString())).StatusCode); + } + + await RecordAsync(Today.AddDays(-4), 60, submit: true); + await RecordAsync(Today.AddDays(-3), 0, submit: true); // the flock laid nothing, and someone said so + // Today-2: nobody recorded anything at all. + await RecordAsync(Today.AddDays(-1), 90, submit: false); // a Draft is not an official entry + + var report = await client.GetFromJsonAsync( + $"/api/v1/reports/production?from={Today.AddDays(-4):yyyy-MM-dd}&to={Today.AddDays(-1):yyyy-MM-dd}"); + + Assert.Equal(4, report!.Days.Count); + var (laid, zero, missing, draftOnly) = (report.Days[0], report.Days[1], report.Days[2], report.Days[3]); + + Assert.Equal((60, 1), (laid.TotalEggs, laid.EntryCount)); + // The pair this issue exists for: identical in every other field. + Assert.Equal(0, zero.TotalEggs); + Assert.Equal(0, missing.TotalEggs); + Assert.Equal(1, zero.EntryCount); + Assert.Equal(0, missing.EntryCount); + // And the Draft sits on the unrecorded side, with its 90 eggs invisible. + Assert.Equal((0, 0), (draftOnly.TotalEggs, draftOnly.EntryCount)); + } + // Depletion writes no removal movement — the flock's contribution must // terminate at DepletedOn anyway (codex review of #92). [Fact] diff --git a/web/src/api/cluckwork.ts b/web/src/api/cluckwork.ts index 7683572f3..69058fe10 100644 --- a/web/src/api/cluckwork.ts +++ b/web/src/api/cluckwork.ts @@ -1082,6 +1082,14 @@ export interface ProductionDay { */ fromCounts: number; deaths: number; + /** + * #780 — how many OFFICIAL entries the day holds. The only field that + * separates a day nobody recorded from one that genuinely produced no eggs: + * every other figure here is 0 for both. Official is Submitted, Locked or + * ManagerAdjusted, so a day holding only a Draft counts 0 here while the + * Dashboard's capture tiles still show it as captured. + */ + entryCount: number; henDays: number; henDayPct: number | null; } diff --git a/web/src/components/DayStrip.test.tsx b/web/src/components/DayStrip.test.tsx index d31db8160..fc9765865 100644 --- a/web/src/components/DayStrip.test.tsx +++ b/web/src/components/DayStrip.test.tsx @@ -1,55 +1,104 @@ // web/src/components/DayStrip.test.tsx import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { DayStrip } from "./DayStrip"; -import type { DayStripData } from "../lib/dashboard"; +import type { DayStripData, DayStripSlot } from "../lib/dashboard"; -const slot = (date: string, value: number, heightPct: number, weekBreak = false) => - ({ date, heightPct, recorded: value > 0, weekBreak }); +const rec = (date: string, eggs: number, heightPct: number, weekBreak = false): DayStripSlot => + ({ kind: "recorded", date, eggs, heightPct, weekBreak }); +const gap = (date: string, weekBreak = false): DayStripSlot => ({ kind: "unrecorded", date, weekBreak }); +// Three days: one that laid, one that laid nothing and was recorded, one that +// nobody recorded. The middle and the last are the pair #780 exists to split. const data: DayStripData = { - slots: [slot("2026-07-01", 10, 100), slot("2026-07-02", 0, 0), slot("2026-07-03", 5, 50, true)], - min: 0, max: 10, last: 5, + slots: [rec("2026-07-01", 10, 100), rec("2026-07-02", 0, 2), gap("2026-07-03", true)], + min: 0, max: 10, average: 5, averagePct: 50, last: null, recorded: 2, unrecorded: 1, }; -describe("DayStrip (#654, #777)", () => { - it("draws a slot for every day and a bar only for a day with a figure", () => { - const { container } = render( - , - ); - // Three days in the window means three slots, including the one with nothing - // recorded — that empty slot is the whole point of the strip. +const tip = (s: DayStripSlot) => + s.kind === "recorded" ? `${s.date} – ${s.eggs} eggs` : `${s.date} – no entry`; + +const strip = (d: DayStripData = data, label = "Eggs per day") => ( + +); + +describe("DayStrip (#654, #777, #780)", () => { + it("draws a slot for every day, and a bar only for a day somebody recorded", () => { + const { container } = render(strip()); expect(container.querySelectorAll(".daystrip > .day")).toHaveLength(3); const bars = Array.from(container.querySelectorAll(".day > i")).map((b) => (b as HTMLElement).style.height); - expect(bars).toEqual(["100%", "50%"]); + // The recorded zero keeps its 2% stub; the unrecorded day has no bar at all. + expect(bars).toEqual(["100%", "2%"]); + }); + + it("places the average line at its share of the peak, and omits it when there is none", () => { + const { container } = render(strip()); + expect((container.querySelector(".avgline") as HTMLElement).style.bottom).toBe("50%"); + const { container: none } = render(strip({ ...data, average: null, averagePct: null })); + expect(none.querySelector(".avgline")).toBeNull(); }); it("marks the week boundary on exactly the slot that carries it", () => { - const { container } = render( - , - ); + const { container } = render(strip()); const marked = Array.from(container.querySelectorAll(".daystrip > .day")) .map((d) => d.classList.contains("day-week")); expect(marked).toEqual([false, false, true]); }); - it("names the picture, the scale and both ends of the window, and follows a re-render", () => { - const { rerender, container } = render( - , - ); - expect(screen.getByRole("img", { name: "Eggs per day" })).toBeInTheDocument(); + it("names the group, the scale and both ends of the window, and follows a re-render", () => { + const { rerender } = render(strip()); + expect(screen.getByRole("group", { name: "Eggs per day" })).toBeInTheDocument(); expect(screen.getByText("Peak 10")).toBeInTheDocument(); + expect(screen.getByText("Avg 5")).toBeInTheDocument(); expect(screen.getByText("1 Jul")).toBeInTheDocument(); - expect(screen.getByText("3 Jul")).toBeInTheDocument(); - - rerender( - , - ); - expect(screen.getByRole("img", { name: "Flat" })).toBeInTheDocument(); - expect(screen.queryByRole("img", { name: "Eggs per day" })).not.toBeInTheDocument(); - expect(container.querySelectorAll(".day > i")).toHaveLength(0); + + rerender(strip({ slots: [gap("2026-08-01")], min: null, max: null, average: null, averagePct: null, last: null, recorded: 0, unrecorded: 1 }, "Flat")); + expect(screen.getByRole("group", { name: "Flat" })).toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Eggs per day" })).not.toBeInTheDocument(); + }); + + // Every day carries its own sentence as the slot's accessible name, so the + // readout is not pointer-only — the figure appears nowhere else on the panel. + it("gives every slot its own name, saying 'no entry' rather than a count of zero", () => { + render(strip()); + expect(screen.getByRole("button", { name: "2026-07-01 – 10 eggs" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "2026-07-02 – 0 eggs" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "2026-07-03 – no entry" })).toBeInTheDocument(); + }); + + it("shows that day's readout on hover and marks the slot it describes", async () => { + const user = userEvent.setup(); + const { container } = render(strip()); + expect(container.querySelector(".tip")).toBeNull(); + + const days = Array.from(container.querySelectorAll(".daystrip > .day")); + await user.hover(days[2]); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-03 – no entry"); + // The SLOT is marked, not just the bar — the day it names has no bar. + expect(days.map((d) => d.classList.contains("on"))).toEqual([false, false, true]); + + await user.hover(days[0]); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-01 – 10 eggs"); + expect(days.map((d) => d.classList.contains("on"))).toEqual([true, false, false]); + }); + + it("opens the readout from the keyboard and closes it on blur", async () => { + const user = userEvent.setup(); + const { container } = render(strip()); + await user.tab(); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-01 – 10 eggs"); + await user.tab(); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-02 – 0 eggs"); + }); + + // The dock is reserved whether or not a day is selected, so the box appearing + // reflows nothing below it. + it("keeps the readout's row present with nothing selected", () => { + const { container } = render(strip()); + expect(container.querySelector(".tipdock")).toBeInTheDocument(); }); }); diff --git a/web/src/components/DayStrip.tsx b/web/src/components/DayStrip.tsx index 28e3aa1c1..10701f71c 100644 --- a/web/src/components/DayStrip.tsx +++ b/web/src/components/DayStrip.tsx @@ -1,40 +1,101 @@ // web/src/components/DayStrip.tsx -import type { DayStripData } from "../lib/dashboard"; +import { useLayoutEffect, useRef, useState } from "react"; +import type { DayStripData, DayStripSlot } from "../lib/dashboard"; -// #654 → #777 — the Dashboard's "Last 14 days", one slot per day. Geometry -// arrives computed (lib/dashboard.ts dayStrip) so this stays a pure renderer, -// and `label` names the picture for a screen reader (role="img"). +// #654 → #777 → #780 — the Dashboard's "Last 14 days", one slot per day. +// Geometry arrives computed (lib/dashboard.ts dayStrip) so this stays a pure +// renderer, and every string arrives already formatted in the farm's locale +// (#650): `tip` turns a slot into the sentence shown for it. // -// A STRIP of days, not a line. Stated at the strength the code supports: the -// line mapped a zero day to the floor of its viewBox, so a zero WAS drawn as a -// drop. What it never drew was that floor or the top of the scale, so nothing -// said the bottom meant zero rather than the window's own minimum; and it -// interpolated between days a daily count has no values between. A slot is -// drawn for every day in the window, so a day with no figure is a visible gap — -// which does NOT make it distinguishable from a true zero day (#780). +// A STRIP of days, not a line. The line it replaces drew a zero day on the +// floor of its viewBox, never drew that floor or the top of the scale, and +// interpolated between days a daily count has no values between. Since #780 a +// recorded day that produced nothing keeps a stub at the baseline while a day +// nobody recorded draws nothing, so the two no longer render alike. // -// `title`, `peak` and the two rule dates arrive already formatted in the farm's -// locale (#650). -export function DayStrip({ data, label, title, peak, from, to }: { +// Each day is a real button rather than a hover-only region: the tooltip is +// the only place a single day's figure appears, and a keyboard user reaching +// the panel must be able to read it. + +// The dock is a RESERVED row that is always present, so the box appearing +// reflows nothing. It costs 2.2rem of panel height permanently — the honest +// price of a readout that covers neither the bars it describes nor the Avg and +// Peak figures the reader compares them against. Placed inside the plot it +// covered six neighbouring bars at the peak. +export function DayStrip({ data, label, title, peak, average, tip, from, to }: { data: DayStripData; label: string; title: string; peak: string; + average: string | null; + tip: (slot: DayStripSlot) => string; from: React.ReactNode; to: React.ReactNode; }) { + const [active, setActive] = useState<{ slot: DayStripSlot; centerPx: number } | null>(null); + const dockRef = useRef(null); + const tipRef = useRef(null); + + // Clamp the box inside the panel against MEASURED widths. It sizes to its own + // text, which runs roughly 30% longer in tl than in en (#688), so nothing + // here may assume a width. The arrow is anchored to the slot element instead + // (.day.on::after), never positioned by index arithmetic: the slots carry a + // 4px gap plus the 9px week break, so (i + 0.5) / n is not where a slot is. + useLayoutEffect(() => { + const dock = dockRef.current; + const box = tipRef.current; + if (dock === null || box === null || active === null) return; + const half = box.offsetWidth / 2; + const x = half * 2 >= dock.offsetWidth + ? dock.offsetWidth / 2 + : Math.min(Math.max(active.centerPx, half), dock.offsetWidth - half); + box.style.left = `${x}px`; + }, [active]); + + const show = (slot: DayStripSlot) => (e: { currentTarget: HTMLButtonElement }) => { + const el = e.currentTarget; + setActive({ slot, centerPx: el.offsetLeft + el.offsetWidth / 2 }); + }; + return (
{title} - {peak} + + {average !== null && {average}} + {peak} +
-
- {data.slots.map((s) => ( - - {s.recorded && } - - ))} +
+ {active !== null && ( + {tip(active.slot)} + )} +
+
setActive(null)} + > + {data.averagePct !== null && ( +
{from} @@ -43,3 +104,19 @@ export function DayStrip({ data, label, title, peak, from, to }: {
); } + +// Narrowing on `kind` is what makes an unrecorded day undrawable: there is no +// height to read on that arm. A fourth state fails to compile here rather than +// silently rendering a bar. +function Bar({ slot }: { slot: DayStripSlot }) { + switch (slot.kind) { + case "unrecorded": + return null; + case "recorded": + return ; + default: { + const _exhaustive: never = slot; + return _exhaustive; + } + } +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index aff145a61..ab9d7c667 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -780,13 +780,26 @@ export const en = { // per day, one slot per day; the figure below it is the server's hen-day % // for the last 7 complete days, and its delta against the 7 before. trendPanelTitle: "Last 14 days", - trendStripLabel: "Eggs per day, last 14 days: lowest {{min}}, peak {{max}}, yesterday {{last}}", - // Same sentence with the empty slots named. Whole string, not a suffix, - // so a locale can put the clause where its grammar wants it. - trendStripLabelBlanks_one: "Eggs per day, last 14 days: lowest {{min}}, peak {{max}}, yesterday {{last}}. {{blank}} day has nothing recorded.", - trendStripLabelBlanks_other: "Eggs per day, last 14 days: lowest {{min}}, peak {{max}}, yesterday {{last}}. {{blank}} days have nothing recorded.", + // The group's summary. Every day is also a button carrying its own figure, + // so this no longer repeats the lowest and the latest — it says what the + // scale is and how much of the window is missing. Whole strings, not a + // stem plus a suffix, so a locale can order its own clauses (#650). + trendStripLabel: "Eggs per day, last 14 days. Peak {{max}}, average {{avg}}. Every day has an entry.", + trendStripLabelBlanks_one: "Eggs per day, last 14 days. Peak {{max}}, average {{avg}} across the recorded days. {{blank}} day has no entry.", + trendStripLabelBlanks_other: "Eggs per day, last 14 days. Peak {{max}}, average {{avg}} across the recorded days. {{blank}} days have no entry.", + // No day in the window holds an entry, so there is no peak and no average + // to name — reporting either as 0 is the conflation #780 closed. + trendStripLabelNone: "Eggs per day, last 14 days. No day in this window has an entry.", trendScaleTitle: "Eggs per day", trendPeak: "Peak {{total}}", + trendAvg: "Avg {{total}}", + // The readout for one day, and the accessible name of that day's slot. + // The dash separates the date from the figure in every locale. + trendDayTip_one: "{{date}} – {{total}} egg", + trendDayTip_other: "{{date}} – {{total}} eggs", + // Nobody recorded that day. Distinct from a day that produced no eggs, + // which reads as "0 eggs" above and keeps a stub on the chart (#780). + trendDayTipNone: "{{date}} – no entry", henDaySubLabel: "Hen-day, last 7 days against the 7 before", henDayDeltaUp: "+{{delta}} pts", henDayDeltaDown: "−{{delta}} pts", @@ -2607,9 +2620,12 @@ export const en = { + "it to open Daily entry for that flock and day. Past twelve tiles, a link carries the rest.", dashboardTrend: "Last 14 days: eggs per day from the production report, with hen-day %" - + " for the last 7 complete days and how it moved against the 7 before. The window is " - + "fixed (yesterday back) and counts submitted days only — a day still in Draft " - + "reads as zero until it is submitted, unlike Reports where you choose the range.", + + " for the last 7 complete days and how it moved against the 7 before. Point at a day, " + + "or tab to it, to read that day on its own. A day the farm submitted with no eggs keeps a mark " + + "on the baseline; a day nobody submitted is an empty slot and reads as No entry" + + ". Avg is the average of the days that do have one. The window is fixed " + + "(yesterday back) and counts submitted days only — a day still in Draft reads " + + "as No entry until it is submitted, unlike Reports where you choose the range.", dashboardStock: "Stock: one stacked bar of what is available by grade, with the " + "figures beside it; restricted eggs are noted after the total. It is the same total the Stock " diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index e802da320..0084e245e 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -595,11 +595,16 @@ export const es = { // Últimos 14 días (#654) trendPanelTitle: "Últimos 14 días", - trendStripLabel: "Huevos por día, últimos 14 días: mínimo {{min}}, máximo {{max}}, ayer {{last}}", - trendStripLabelBlanks_one: "Huevos por día, últimos 14 días: mínimo {{min}}, máximo {{max}}, ayer {{last}}. {{blank}} día no tiene nada registrado.", - trendStripLabelBlanks_other: "Huevos por día, últimos 14 días: mínimo {{min}}, máximo {{max}}, ayer {{last}}. {{blank}} días no tienen nada registrado.", + trendStripLabel: "Huevos por día, últimos 14 días. Máximo {{max}}, promedio {{avg}}. Todos los días tienen un registro.", + trendStripLabelBlanks_one: "Huevos por día, últimos 14 días. Máximo {{max}}, promedio {{avg}} entre los días registrados. {{blank}} día no tiene registro.", + trendStripLabelBlanks_other: "Huevos por día, últimos 14 días. Máximo {{max}}, promedio {{avg}} entre los días registrados. {{blank}} días no tienen registro.", + trendStripLabelNone: "Huevos por día, últimos 14 días. Ningún día de este período tiene registro.", trendScaleTitle: "Huevos por día", trendPeak: "Máximo {{total}}", + trendAvg: "Prom. {{total}}", + trendDayTip_one: "{{date}} – {{total}} huevo", + trendDayTip_other: "{{date}} – {{total}} huevos", + trendDayTipNone: "{{date}} – sin registro", henDaySubLabel: "Postura, últimos 7 días frente a los 7 anteriores", henDayDeltaUp: "+{{delta}} pts", henDayDeltaDown: "−{{delta}} pts", @@ -1997,10 +2002,13 @@ export const es = { + "tarjetas, un enlace lleva al resto.", dashboardTrend: "Últimos 14 días: huevos por día del informe de producción, con el % de " - + "postura de los últimos 7 días completos y su variación frente a los 7 anteriores. La " - + "ventana es fija (desde ayer hacia atrás) y cuenta solo días enviados — un día " - + "aún en borrador se lee como cero hasta que se envía, a diferencia de Informes, donde usted elige " - + "el rango.", + + "postura de los últimos 7 días completos y su variación frente a los 7 anteriores. " + + "Señale un día, o llegue a él con el tabulador, para leerlo por separado. Un día que la granja " + + "envió sin huevos conserva una marca en la base; un día que nadie envió queda vacío y se lee como " + + "Sin registro. Prom. es el promedio de los días que sí lo " + + "tienen. La ventana es fija (desde ayer hacia atrás) y cuenta solo días enviados " + + "— un día aún en borrador se lee como Sin registro hasta que se envía, a diferencia de Informes, " + + "donde usted elige el rango.", dashboardStock: "Existencias: una barra apilada de lo disponible por grado, con " + "las cifras al lado; los huevos restringidos se anotan tras el total. Es el mismo total que " diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 13f893d89..5752163dc 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -620,11 +620,16 @@ export const tl = { // Huling 14 araw (#654) trendPanelTitle: "Huling 14 araw", - trendStripLabel: "Itlog bawat araw, huling 14 araw: pinakamababa {{min}}, pinakamataas {{max}}, kahapon {{last}}", - trendStripLabelBlanks_one: "Itlog bawat araw, huling 14 araw: pinakamababa {{min}}, pinakamataas {{max}}, kahapon {{last}}. {{blank}} araw ang walang naitalang datos.", - trendStripLabelBlanks_other: "Itlog bawat araw, huling 14 araw: pinakamababa {{min}}, pinakamataas {{max}}, kahapon {{last}}. {{blank}} araw ang walang naitalang datos.", + trendStripLabel: "Itlog bawat araw, huling 14 araw. Pinakamataas {{max}}, katamtaman {{avg}}. Bawat araw ay may tala.", + trendStripLabelBlanks_one: "Itlog bawat araw, huling 14 araw. Pinakamataas {{max}}, katamtaman {{avg}} sa mga araw na may tala. {{blank}} araw ang walang tala.", + trendStripLabelBlanks_other: "Itlog bawat araw, huling 14 araw. Pinakamataas {{max}}, katamtaman {{avg}} sa mga araw na may tala. {{blank}} araw ang walang tala.", + trendStripLabelNone: "Itlog bawat araw, huling 14 araw. Walang araw sa panahong ito ang may tala.", trendScaleTitle: "Itlog bawat araw", trendPeak: "Pinakamataas {{total}}", + trendAvg: "Katamtaman {{total}}", + trendDayTip_one: "{{date}} – {{total}} itlog", + trendDayTip_other: "{{date}} – {{total}} itlog", + trendDayTipNone: "{{date}} – walang tala", henDaySubLabel: "Hen-day, huling 7 araw laban sa naunang 7", henDayDeltaUp: "+{{delta}} pts", henDayDeltaDown: "−{{delta}} pts", @@ -2075,9 +2080,13 @@ export const tl = { dashboardTrend: "Huling 14 araw: itlog bawat araw mula sa report ng produksyon, na may " + "hen-day % para sa huling 7 kumpletong araw at kung paano ito gumalaw kumpara " - + "sa 7 araw bago nito. Nakapirmi ang window (mula kahapon pabalik) at binibilang lang ang " - + "mga naisumiteng araw — ang araw na Draft pa ay mababasang zero hanggang " - + "maisumite, hindi tulad sa Mga Report kung saan pinipili mo ang saklaw.", + + "sa 7 araw bago nito. Ituro ang isang araw, o puntahan ito gamit ang tab, para basahin ito nang " + + "mag-isa. Ang araw na isinumite nang walang itlog ay may marka sa base; ang araw na walang " + + "nagsumite ay walang laman at mababasang Walang tala. Ang " + + "Katamtaman ay ang average ng mga araw na may tala. Nakapirmi ang window (mula " + + "kahapon pabalik) at binibilang lang ang mga naisumiteng araw — ang araw na " + + "Draft pa ay mababasang Walang tala hanggang maisumite, hindi tulad sa Mga Report kung saan " + + "pinipili mo ang saklaw.", dashboardStock: "Stock: isang stacked bar ng available bawat grado, na may mga " + "bilang sa tabi; ang restricted na itlog ay nakatala pagkatapos ng kabuuan. Ito rin ang kabuuang " diff --git a/web/src/lib/dashboard.test.ts b/web/src/lib/dashboard.test.ts index a75f75213..a621e5e53 100644 --- a/web/src/lib/dashboard.test.ts +++ b/web/src/lib/dashboard.test.ts @@ -18,10 +18,14 @@ const entry = (flockId: string, status: string, totalEggs: number, id = `de-${fl crackedGradeId: null, dirtyGradeId: null, grades: [], version: 1, adjustReason: null, voidReason: null, lockedAtUtc: null, adjustedFrom: null, }); -const day = (date: string, totalEggs: number, henDays = 100): ProductionDay => ({ +// `entryCount` defaults to 1: a day with a figure is a day somebody recorded. +// Pass 0 for the day nobody recorded — the distinction #780 added. +const day = (date: string, totalEggs: number, henDays = 100, entryCount = 1): ProductionDay => ({ date, totalEggs, cracked: 0, dirty: 0, discarded: 0, sellable: totalEggs, fromCounts: 0, - deaths: 0, henDays, henDayPct: henDays > 0 ? Math.round((totalEggs * 1000) / henDays) / 10 : null, + deaths: 0, entryCount, henDays, + henDayPct: henDays > 0 ? Math.round((totalEggs * 1000) / henDays) / 10 : null, }); +const missing = (date: string) => day(date, 0, 100, 0); const report = (periodHenDayPct: number | null, days: ProductionDay[] = []): ProductionReport => ({ days, totalEggs: 0, totalSellable: 0, totalFromCounts: 0, totalDeaths: 0, totalHenDays: 0, periodHenDayPct, gradeTotals: [], @@ -93,52 +97,83 @@ describe("todaysEggs (#654, INV-3)", () => { }); }); -describe("dayStrip (#654, #777 — one slot per day, bars anchored at zero)", () => { - const shape = (d: ReturnType) => d.slots.map((s) => [s.date, s.heightPct, s.recorded]); +describe("dayStrip (#654, #777, #780 — one slot per day, bars anchored at zero)", () => { + const shape = (d: ReturnType) => + d.slots.map((s) => [s.date, s.kind, s.kind === "recorded" ? s.heightPct : null]); it("sizes each bar as its share of the peak, oldest first", () => { - const d = dayStrip([day("2026-07-01", 5), day("2026-07-02", 10), day("2026-07-03", 8)]); - expect(shape(d)).toEqual([["2026-07-01", 50, true], ["2026-07-02", 100, true], ["2026-07-03", 80, true]]); - expect(d).toMatchObject({ min: 5, max: 10, last: 8 }); + const d = dayStrip({ days: [day("2026-07-01", 5), day("2026-07-02", 10), day("2026-07-03", 8)] }); + expect(shape(d)).toEqual([ + ["2026-07-01", "recorded", 50], ["2026-07-02", "recorded", 100], ["2026-07-03", "recorded", 80], + ]); + expect(d).toMatchObject({ min: 5, max: 10, last: 8, average: 7.7, recorded: 3, unrecorded: 0 }); + }); + + // The whole of #780 in one assertion. Before `entryCount` both of these days + // arrived as totalEggs 0 and drew the same empty slot, so the chart asserted + // a zero it had no evidence for. + it("separates a day that produced no eggs from a day nobody recorded", () => { + const d = dayStrip({ days: [day("2026-07-01", 400), day("2026-07-02", 0), missing("2026-07-03")] }); + expect(shape(d)).toEqual([ + ["2026-07-01", "recorded", 100], + // Recorded and genuinely zero: a stub at the baseline, not nothing. + ["2026-07-02", "recorded", 2], + ["2026-07-03", "unrecorded", null], + ]); + expect(d).toMatchObject({ recorded: 2, unrecorded: 1 }); }); - it("gives a day with no figure a zero-height, unrecorded slot rather than dropping it", () => { - // The line this replaced drew these days on the floor of its viewBox, with - // that floor never drawn — so nothing said the bottom meant zero rather - // than the window's own minimum. A slot makes the day itself visible. - const d = dayStrip([day("2026-07-01", 400), day("2026-07-02", 0), day("2026-07-03", 200)]); - expect(shape(d)).toEqual([["2026-07-01", 100, true], ["2026-07-02", 0, false], ["2026-07-03", 50, true]]); - expect(d.slots).toHaveLength(3); + it("computes min, max and the average over the recorded days only", () => { + // The unrecorded day is worth nothing to any of the three. Averaging over + // the calendar rather than over the evidence would report 150, and would + // call the window's minimum 0. + const d = dayStrip({ days: [day("2026-07-01", 300), missing("2026-07-02"), day("2026-07-03", 200)] }); + expect(d).toMatchObject({ min: 200, max: 300, average: 250, averagePct: 83.3 }); + }); + + it("reports no last figure when nobody recorded the final day", () => { + // "Yesterday 0" was a claim the data never supported. + expect(dayStrip({ days: [day("2026-07-01", 90), missing("2026-07-02")] }).last).toBeNull(); + expect(dayStrip({ days: [missing("2026-07-01"), day("2026-07-02", 0)] }).last).toBe(0); }); it("floors a recorded day at 2% so the farm's worst real day is still a bar", () => { - const d = dayStrip([day("2026-07-01", 1000), day("2026-07-02", 3)]); - expect(d.slots[1]).toMatchObject({ date: "2026-07-02", heightPct: 2, recorded: true }); + const d = dayStrip({ days: [day("2026-07-01", 1000), day("2026-07-02", 3)] }); + expect(d.slots[1]).toMatchObject({ date: "2026-07-02", kind: "recorded", heightPct: 2 }); }); it("marks the week boundary at the start of the recent window, and nowhere else", () => { const days = Array.from({ length: 14 }, (_, i) => day(`2026-07-${String(i + 1).padStart(2, "0")}`, 100 + i)); - const breaks = dayStrip(days, 7).slots.map((s) => s.weekBreak); + const breaks = dayStrip({ days, recentCount: 7 }).slots.map((s) => s.weekBreak); expect(breaks.filter(Boolean)).toHaveLength(1); expect(breaks.indexOf(true)).toBe(7); - expect(dayStrip(days, 7).slots[7].date).toBe("2026-07-08"); + expect(dayStrip({ days, recentCount: 7 }).slots[7].date).toBe("2026-07-08"); }); it("draws no boundary when the recent window is absent or is the whole window", () => { const days = [day("2026-07-01", 1), day("2026-07-02", 2)]; - expect(dayStrip(days).slots.some((s) => s.weekBreak)).toBe(false); - expect(dayStrip(days, 2).slots.some((s) => s.weekBreak)).toBe(false); + expect(dayStrip({ days }).slots.some((s) => s.weekBreak)).toBe(false); + expect(dayStrip({ days, recentCount: 2 }).slots.some((s) => s.weekBreak)).toBe(false); + }); + + it("keeps a stub on every day when every recorded day is zero, and draws no average line", () => { + // A real average of 0 with no scale to place it on. The line would sit on + // the floor and read as data. + const d = dayStrip({ days: [day("2026-07-01", 0), day("2026-07-02", 0)] }); + expect(shape(d)).toEqual([["2026-07-01", "recorded", 2], ["2026-07-02", "recorded", 2]]); + expect(d).toMatchObject({ min: 0, max: 0, average: 0, averagePct: null }); }); - it("draws an all-empty strip when every day is zero, without dividing by zero", () => { - const d = dayStrip([day("2026-07-01", 0), day("2026-07-02", 0)]); - expect(shape(d)).toEqual([["2026-07-01", 0, false], ["2026-07-02", 0, false]]); - expect(d).toMatchObject({ min: 0, max: 0, last: 0 }); + it("has no peak and no average when no day in the window was recorded", () => { + const d = dayStrip({ days: [missing("2026-07-01"), missing("2026-07-02")] }); + expect(shape(d)).toEqual([["2026-07-01", "unrecorded", null], ["2026-07-02", "unrecorded", null]]); + expect(d).toMatchObject({ min: null, max: null, average: null, last: null, recorded: 0, unrecorded: 2 }); }); it("is empty for no days and one full-height slot for one day", () => { - expect(dayStrip([])).toEqual({ slots: [], min: 0, max: 0, last: 0 }); - expect(dayStrip([day("2026-07-01", 7)]).slots[0]).toMatchObject({ heightPct: 100, recorded: true }); + expect(dayStrip({ days: [] })).toMatchObject({ slots: [], max: null, average: null, last: null }); + expect(dayStrip({ days: [day("2026-07-01", 7)] }).slots[0]) + .toMatchObject({ kind: "recorded", heightPct: 100 }); }); }); diff --git a/web/src/lib/dashboard.ts b/web/src/lib/dashboard.ts index c84a0a44b..4b6c81b41 100644 --- a/web/src/lib/dashboard.ts +++ b/web/src/lib/dashboard.ts @@ -40,9 +40,8 @@ export function todaysEggs(entries: DailyEntry[]): number { return entries.filter((e) => e.status !== "Voided").reduce((a, e) => a + e.totalEggs, 0); } -// One slot per day in the window, whether or not that day has a figure (#777). -// `heightPct` is the bar's share of the tallest day; `recorded` is false for a -// day that produced nothing, which the strip draws as an empty slot. +// One slot per day in the window, whether or not that day has a figure (#777), +// and — since #780 — whether or not anyone recorded it. // // Why a strip rather than the line it replaces, stated at the strength the code // supports: the line mapped a zero day to y = SPARK_H, the floor of the viewBox, @@ -50,39 +49,87 @@ export function todaysEggs(entries: DailyEntry[]): number { // the floor itself or the top of the scale, so nothing on screen said the bottom // meant zero rather than the window's own minimum, and a 3% swing and a 60% one // made the same picture. It also interpolated between days, implying values -// between them that a daily count does not have. The strip fixes those two. -// It does NOT fix the ambiguity below, which is #780. +// between them that a daily count does not have. // -// KNOWN GAP (#780): the production report cannot yet say -// whether a day was ENTERED. `ReportQueries` walks `for (d = from; d <= to; -// d = d.AddDays(1))` and emits every calendar day with `total = row?.Total ?? 0`, -// so a day nobody recorded and a day that genuinely produced zero eggs arrive -// identical. `recorded: false` therefore means "no eggs", not "no entry", and -// nothing here may average over the window until the server can tell them apart. -export interface DayStripSlot { date: string; heightPct: number; recorded: boolean; weekBreak: boolean } -export interface DayStripData { slots: DayStripSlot[]; min: number; max: number; last: number } +// The three states are a discriminated union rather than a `recorded` flag +// beside a `heightPct`, which permitted an unrecorded day carrying a height of +// 40 — the same defect class #780 exists to fix, one layer up. A renderer +// narrowing on `kind` cannot draw a bar for a day that has none. +export type DayStripSlot = + | { kind: "unrecorded"; date: string; weekBreak: boolean } + | { kind: "recorded"; date: string; eggs: number; heightPct: number; weekBreak: boolean }; + +export interface DayStripData { + slots: DayStripSlot[]; + // Every figure below is over the RECORDED days only, and is null when the + // window holds none. A window of unrecorded days has no minimum and no + // average; reporting 0 would be the conflation this issue closed. + min: number | null; + max: number | null; + average: number | null; + // The average as a share of the peak, for the reference line. Separate from + // `average` because the line is geometry and the figure is a count. + averagePct: number | null; + // The window's last day: its eggs if it was recorded, null if nobody + // recorded it. "Yesterday 0" was a claim the data never supported. + last: number | null; + recorded: number; + unrecorded: number; +} const r1 = (n: number) => Math.round(n * 10) / 10; // A bar is anchored at zero and its height is its share of the peak. Not a // cropped axis: the panel answers "did production hold", and shortening the // axis to dramatise a small swing would misstate the ratios between days. -// A recorded day floors at 2% so the shortest real day is still a bar. -// `recentCount` is the length of the LATER of the two windows the panel fetches, -// so the strip's divider falls exactly where the hen-day caption's comparison -// does. 0 (or the whole window) draws no divider. -export function dayStrip(days: ProductionDay[], recentCount = 0): DayStripData { - if (days.length === 0) return { slots: [], min: 0, max: 0, last: 0 }; - const values = days.map((d) => d.totalEggs); - const max = Math.max(...values); +// +// Every RECORDED day floors at 2%, including one that produced no eggs — that +// stub at the baseline is the whole visible difference between "the flock laid +// nothing and someone said so" and "nobody looked". An unrecorded day draws no +// bar at all, so the two can never render alike. +// +// `recentCount` is the length of the LATER of the two windows the panel +// fetches, so the strip's divider falls exactly where the hen-day caption's +// comparison does. 0 (or the whole window) draws no divider. +export function dayStrip({ days, recentCount = 0 }: { days: ProductionDay[]; recentCount?: number }): DayStripData { + const empty: DayStripData = { + slots: [], min: null, max: null, average: null, averagePct: null, + last: null, recorded: 0, unrecorded: 0, + }; + if (days.length === 0) return empty; + + const values = days.filter((d) => d.entryCount > 0).map((d) => d.totalEggs); + const max = values.length === 0 ? null : Math.max(...values); + const average = values.length === 0 + ? null + : r1(values.reduce((a, v) => a + v, 0) / values.length); const breakAt = recentCount > 0 && recentCount < days.length ? days.length - recentCount : -1; - const slots = days.map((d, i) => ({ - date: d.date, - heightPct: d.totalEggs > 0 && max > 0 ? Math.max(2, r1((d.totalEggs / max) * 100)) : 0, - recorded: d.totalEggs > 0, - weekBreak: i === breakAt, - })); - return { slots, min: Math.min(...values), max, last: values[values.length - 1] }; + + const slots: DayStripSlot[] = days.map((d, i) => { + const weekBreak = i === breakAt; + if (d.entryCount === 0) return { kind: "unrecorded", date: d.date, weekBreak }; + return { + kind: "recorded", + date: d.date, + eggs: d.totalEggs, + heightPct: max !== null && max > 0 ? Math.max(2, r1((d.totalEggs / max) * 100)) : 2, + weekBreak, + }; + }); + + const final = slots[slots.length - 1]; + return { + slots, + min: values.length === 0 ? null : Math.min(...values), + max, + average, + // A window whose every recorded day is zero has a real average of 0 and no + // scale to place it on, so it gets no line rather than one on the floor. + averagePct: average === null || max === null || max === 0 ? null : r1((average / max) * 100), + last: final.kind === "recorded" ? final.eggs : null, + recorded: values.length, + unrecorded: days.length - values.length, + }; } export interface HenDayTrend { current: number | null; previous: number | null; delta: number | null } diff --git a/web/src/routes/Dashboard.test.tsx b/web/src/routes/Dashboard.test.tsx index 55aaddc92..8f844d296 100644 --- a/web/src/routes/Dashboard.test.tsx +++ b/web/src/routes/Dashboard.test.tsx @@ -42,9 +42,11 @@ const entry = (flockId: string, status: string, totalEggs: number): DailyEntry = crackedGradeId: null, dirtyGradeId: null, grades: [], version: 1, adjustReason: null, voidReason: null, lockedAtUtc: null, adjustedFrom: null, }); -const day = (date: string, totalEggs: number): ProductionDay => ({ +// `entryCount` defaults to 1 — a day with a figure is a day somebody recorded. +// 0 is the day nobody recorded, which #780 made distinguishable from a zero. +const day = (date: string, totalEggs: number, entryCount = 1): ProductionDay => ({ date, totalEggs, cracked: 0, dirty: 0, discarded: 0, sellable: totalEggs, fromCounts: 0, - deaths: 0, henDays: 100, henDayPct: totalEggs, + deaths: 0, entryCount, henDays: 100, henDayPct: totalEggs, }); const report = (periodHenDayPct: number | null, days: ProductionDay[]): ProductionReport => ({ days, totalEggs: 0, totalSellable: 0, totalFromCounts: 0, totalDeaths: 0, totalHenDays: 0, @@ -177,7 +179,11 @@ describe("Dashboard last 14 days (#654, INV-5)", () => { it("draws the 14 report days oldest-first as bars sized off the peak, and the server's hen-day figures", async () => { renderWithProviders(); - const strip = await screen.findByRole("img", { name: "Eggs per day, last 14 days: lowest 301, peak 327, yesterday 321" }); + // 4,396 eggs over 14 recorded days is 314.0 — the average is over the days + // with an entry, which is a figure that only exists since #780. + const strip = await screen.findByRole("group", { + name: "Eggs per day, last 14 days. Peak 327, average 314.0. Every day has an entry.", + }); // 301..307 then 321..327, so the peak (327) is the 8th day and every other // bar is its exact share of it. expect(Array.from(strip.querySelectorAll(".day > i")).map((b) => (b as HTMLElement).style.height)).toEqual([ @@ -194,23 +200,56 @@ describe("Dashboard last 14 days (#654, INV-5)", () => { expect(screen.getByText("Hen-day, last 7 days against the 7 before")).toBeInTheDocument(); }); - it("names the empty slots in the accessible label, so a zero run is not announced as 'lowest 0'", async () => { + // Days 4..6 of each window hold no entry at all. `entryCount` is the only + // field that says so — before #780 these arrived as totalEggs 0, identical + // to a day the farm recorded as having produced nothing. + const withUnrecordedTail = () => mockReport.mockImplementation((from, to) => - reportByWindow(today)(from, to).then((r) => ({ ...r, days: r.days.map((d, i) => (i > 3 ? { ...d, totalEggs: 0 } : d)) }))); + reportByWindow(today)(from, to).then((r) => ({ + ...r, days: r.days.map((d, i) => (i > 3 ? { ...d, totalEggs: 0, entryCount: 0 } : d)), + }))); + + it("names the days with no entry in the accessible label, and averages over the rest", async () => { + withUnrecordedTail(); renderWithProviders(); - expect(await screen.findByRole("img", { name: /6 days have nothing recorded\.$/ })).toBeInTheDocument(); + expect(await screen.findByRole("group", { name: /6 days have no entry\.$/ })).toBeInTheDocument(); }); - it("draws an empty slot for a day with nothing recorded, never a bar through it", async () => { - // The line this replaced put these days on the floor of its viewBox and - // never drew that floor, so the bottom of the picture was unlabelled. Each - // day is now its own slot. + it("draws an empty slot for a day nobody recorded, and a stub for one that produced nothing", async () => { + withUnrecordedTail(); + renderWithProviders(); + const strip = await screen.findByRole("group", { name: /Eggs per day, last 14 days/ }); + expect(strip.querySelectorAll(".day")).toHaveLength(14); + // 8 recorded days draw a bar; the 6 with no entry draw nothing. + expect(strip.querySelectorAll(".day > i")).toHaveLength(8); + }); + + // The pair this issue exists for, on one screen: a day that recorded zero + // keeps a 2% stub, a day nobody recorded has no bar at all. Before #780 both + // drew the same nothing. + it("draws a stub for a recorded zero beside the empty slot of an unrecorded day", async () => { mockReport.mockImplementation((from, to) => - reportByWindow(today)(from, to).then((r) => ({ ...r, days: r.days.map((d, i) => (i > 3 ? { ...d, totalEggs: 0 } : d)) }))); + reportByWindow(today)(from, to).then((r) => ({ + ...r, days: r.days.map((d, i) => (i > 3 ? { ...d, totalEggs: 0, entryCount: i > 5 ? 0 : 1 } : d)), + }))); renderWithProviders(); - const strip = await screen.findByRole("img", { name: /Eggs per day, last 14 days/ }); + const strip = await screen.findByRole("group", { name: /Eggs per day, last 14 days/ }); + const heights = Array.from(strip.querySelectorAll(".day > i")).map((b) => (b as HTMLElement).style.height); + // 8 days with real figures, plus days 4 and 5 of each window at the stub. + expect(heights).toHaveLength(12); + expect(heights.filter((h) => h === "2%")).toHaveLength(4); expect(strip.querySelectorAll(".day")).toHaveLength(14); - expect(strip.querySelectorAll(".day > i")).toHaveLength(8); // 4 per window + }); + + it("says 'no entry' for an unrecorded day rather than a count of zero", async () => { + withUnrecordedTail(); + renderWithProviders(); + await screen.findByRole("group", { name: /Eggs per day, last 14 days/ }); + const names = screen.getAllByRole("button") + .map((b) => b.getAttribute("aria-label")) + .filter((n): n is string => n !== null && n.includes("–")); + expect(names.filter((n) => n.endsWith("no entry"))).toHaveLength(6); + expect(names.filter((n) => n.endsWith("0 eggs"))).toHaveLength(0); }); it("shows a negative delta with the minus form, one decimal on both figures", async () => { diff --git a/web/src/routes/Dashboard.tsx b/web/src/routes/Dashboard.tsx index 2f7e33495..d030a9b24 100644 --- a/web/src/routes/Dashboard.tsx +++ b/web/src/routes/Dashboard.tsx @@ -20,6 +20,7 @@ import { daysBefore } from "../lib/dates"; import { captureTiles, dayStrip, henDayTrend, stockBar, todaysEggs, visibleTiles, } from "../lib/dashboard"; +import type { DayStripData, DayStripSlot } from "../lib/dashboard"; import i18n from "../i18n"; import { statusLabel } from "../i18n/enums"; @@ -106,7 +107,10 @@ export function Dashboard() { const panelError =

{t("panelLoadError")}

; const tiles = flocks !== null && entries !== null ? visibleTiles(captureTiles(flocks, entries)) : null; const trendData = trend === null ? null : { - line: dayStrip([...trend.previous.days, ...trend.current.days], trend.current.days.length), + line: dayStrip({ + days: [...trend.previous.days, ...trend.current.days], + recentCount: trend.current.days.length, + }), henDay: henDayTrend(trend.current, trend.previous), }; const bar = stock === null ? null : stockBar(stock); @@ -123,13 +127,22 @@ export function Dashboard() { // screen-reader user still gets "lowest 0", the conflation the redraw // removed for everyone else. Two complete sentences rather than one built by // concatenation, so each locale can order its own clauses. - const trendLabel = (line: { slots: { recorded: boolean }[]; min: number; max: number; last: number }) => { - const blank = line.slots.filter((s) => !s.recorded).length; - const figures = { min: fmt.count(line.min), max: fmt.count(line.max), last: fmt.count(line.last) }; - return blank === 0 + const trendLabel = (line: DayStripData) => { + // max and average are null together — both are over the recorded days — + // so a window with none gets its own sentence rather than a formatted 0. + if (line.max === null || line.average === null) return t("trendStripLabelNone"); + const figures = { max: fmt.count(line.max), avg: fmt.count(line.average, 1) }; + return line.unrecorded === 0 ? t("trendStripLabel", figures) - : t("trendStripLabelBlanks", { ...figures, count: blank, blank: fmt.count(blank) }); + : t("trendStripLabelBlanks", { ...figures, count: line.unrecorded, blank: fmt.count(line.unrecorded) }); }; + // One day's readout, and the accessible name of its slot. Both figures are + // farm-locale formatted before they reach a catalog string (#650), and the + // unrecorded arm carries no count at all — that is the point of #780. + const trendTip = (slot: DayStripSlot) => + slot.kind === "recorded" + ? t("trendDayTip", { date: fmt.date(slot.date), count: slot.eggs, total: fmt.count(slot.eggs) }) + : t("trendDayTipNone", { date: fmt.date(slot.date) }); const deltaClass = (delta: number | null) => delta === null || delta === 0 ? "trend-delta" : delta < 0 ? "trend-delta is-down" : "trend-delta is-up"; @@ -191,7 +204,11 @@ export function Dashboard() { data={trendData.line} label={trendLabel(trendData.line)} title={t("trendScaleTitle")} - peak={t("trendPeak", { total: fmt.count(trendData.line.max) })} + peak={trendData.line.max === null ? "—" : t("trendPeak", { total: fmt.count(trendData.line.max) })} + average={trendData.line.average === null + ? null + : t("trendAvg", { total: fmt.count(trendData.line.average, 1) })} + tip={trendTip} from={} to={} /> diff --git a/web/src/routes/HelpPage.test.tsx b/web/src/routes/HelpPage.test.tsx index 34dc11fa6..876eaf20c 100644 --- a/web/src/routes/HelpPage.test.tsx +++ b/web/src/routes/HelpPage.test.tsx @@ -1106,7 +1106,11 @@ describe("HelpPage visual pass (#657)", () => { // The section's Open link: ROUTE_FOR["dashboard"] = "/" resolved against the Worker's own nav. const section = screen.getByRole("heading", { name: "Dashboard", level: 3 }).closest("section")!; expect(within(section).getByRole("link", { name: "Open Dashboard" })).toHaveAttribute("href", "/"); - expect(within(section).getByText("No entry", { selector: "strong" })).toBeInTheDocument(); + // Twice: the tile's badge, and the day strip's empty slot. Both carry the + // control's own label, and the second only became true with #780 — before + // it, an unsubmitted day read as a zero on the chart. + expect(within(section).getAllByText("No entry", { selector: "strong" })).toHaveLength(2); + expect(within(section).getByText(/reads as No entry until it is submitted/i)).toBeInTheDocument(); expect(within(section).getByText(/submitted days only/i)).toBeInTheDocument(); expect(within(section).getByText("stacked bar", { selector: "strong" })).toBeInTheDocument(); expect(screen.getByText("Capture status", { selector: "dt a" })).toBeInTheDocument(); diff --git a/web/src/routes/ReportsPage.test.tsx b/web/src/routes/ReportsPage.test.tsx index dbeb29c75..a10a9fe0a 100644 --- a/web/src/routes/ReportsPage.test.tsx +++ b/web/src/routes/ReportsPage.test.tsx @@ -38,8 +38,8 @@ const mockGetProfitReport = vi.mocked(getProfitReport); // of the `?? "—"` fallback (DATA, left raw per the namespace header comment). const PRODUCTION: ProductionReport = { days: [ - { date: "2026-07-19", totalEggs: 100, cracked: 2, dirty: 3, discarded: 5, sellable: 90, fromCounts: 6, deaths: 1, henDays: 98, henDayPct: 91.8 }, - { date: "2026-07-18", totalEggs: 95, cracked: 1, dirty: 1, discarded: 2, sellable: 91, fromCounts: 0, deaths: 0, henDays: 98, henDayPct: null }, + { date: "2026-07-19", totalEggs: 100, cracked: 2, dirty: 3, discarded: 5, sellable: 90, fromCounts: 6, deaths: 1, entryCount: 1, henDays: 98, henDayPct: 91.8 }, + { date: "2026-07-18", totalEggs: 95, cracked: 1, dirty: 1, discarded: 2, sellable: 91, fromCounts: 0, deaths: 0, entryCount: 1, henDays: 98, henDayPct: null }, ], totalEggs: 195, totalSellable: 181, totalFromCounts: 6, totalDeaths: 1, totalHenDays: 196, periodHenDayPct: 92.3, gradeTotals: [ diff --git a/web/src/styles.css b/web/src/styles.css index 14c0c555d..2f17be87a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1807,8 +1807,9 @@ label.check { never drew that floor or the top of the scale, so nothing said the bottom meant zero rather than the window's own minimum, and it interpolated between days that a daily count has no values between. The strip names both ends of - the scale and draws a slot per day. It does NOT disambiguate a zero day from - an unrecorded one; that is #780. */ + the scale and draws a slot per day. Since #780 it also distinguishes a day + that produced nothing from a day nobody recorded: the first keeps a stub at + the baseline, the second draws no bar. */ .trend { margin: 0; } @@ -1823,12 +1824,53 @@ label.check { margin-bottom: 0.4rem; } -.trend-peak { +.trend-figures { + display: flex; + gap: 0.75rem; + align-items: baseline; +} + +.trend-peak, +.trend-avg { color: var(--ink); font-variant-numeric: tabular-nums; } +/* Avg is the quieter of the two: the peak sets the scale the bars are drawn + against, the average is a reference on it. */ +.trend-avg { + color: var(--muted); +} + +/* The readout's reserved row. Always present, so the box appearing reflows + nothing — the first attempt let it size the row and every hover shoved the + Avg/Peak group sideways. 2.2rem is the permanent cost. */ +.tipdock { + position: relative; + height: 2.2rem; +} + +/* Positioned by a measured clamp from the component, not by index arithmetic. + `left` is written there; everything that does not depend on the text's own + width lives here. */ +.tip { + position: absolute; + bottom: 8px; + transform: translateX(-50%); + max-width: 100%; + padding: 0.15rem 0.45rem; + border-radius: 4px; + background: var(--ink); + color: var(--surface); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .daystrip { + position: relative; display: flex; align-items: flex-end; gap: 4px; @@ -1836,20 +1878,38 @@ label.check { border-bottom: 1px solid var(--hairline); } -/* The slot, drawn whether or not the day has a figure. */ +/* The mean of the RECORDED days, which is a figure that only exists since #780 + — averaging over a window that could not tell an unrecorded day from a zero + would have divided by the calendar rather than by the evidence. */ +.avgline { + position: absolute; + left: 0; + right: 0; + border-top: 1px dashed color-mix(in oklab, var(--ink) 35%, transparent); + pointer-events: none; +} + +/* The slot, drawn whether or not the day has a figure. A button, because the + day's own figure appears nowhere else and a keyboard user has to reach it. */ .day { flex: 1 1 0; display: flex; align-items: flex-end; justify-content: center; + position: relative; height: 100%; + padding: 0; + border: none; border-radius: 3px 3px 0 0; background: color-mix(in oklab, var(--stat-accent) 7%, transparent); + cursor: pointer; + font: inherit; } /* The bar. Anchored at zero and sized as a share of the peak — not a cropped axis: the panel answers "did production hold", and shortening the axis to - dramatise a small swing would misstate the ratios between days. */ + dramatise a small swing would misstate the ratios between days. A recorded + day floors at 2%, so a day that produced nothing still shows a stub. */ .day > i { display: block; width: 100%; @@ -1858,12 +1918,51 @@ label.check { background: var(--stat-accent); } +/* The selected day. The SLOT is outlined, not just the bar: five of fourteen + days in a typical window have no bar to recolour, and on a 2px stub a ring + round the bar reads as a rendering fault rather than a selection. */ +.day.on { + background: color-mix(in oklab, var(--ink) 14%, transparent); + /* An inset outline rather than an inset box-shadow: the dashboard surfaces + carry no shadows (INV-8) and a ring is not one. A negative offset keeps it + inside the slot, so the ring cannot overlap its neighbour. Selection and + keyboard focus coincide here — focusing a slot selects it — so this IS the + focus indicator and no second ring competes with it. */ + outline: 1.5px solid var(--ink); + outline-offset: -1.5px; +} + +.day.on > i { + background: var(--ink); +} + +/* The arrow, anchored to the slot element. Never positioned by index + arithmetic: the slots carry a 4px gap plus the 9px week break, so + (i + 0.5) / n is not where a slot actually is. 5px of arrow in the dock's + 8px gap, so the box cannot swallow it. */ +.day.on::after { + content: ""; + position: absolute; + left: 50%; + bottom: 100%; + margin-bottom: 2px; + transform: translateX(-50%); + /* The CSS triangle, written as longhands: the guard below requires every + colour-valued declaration to resolve through a token or to BE a colour + keyword, and `border: 5px solid transparent` is a shorthand carrying one + rather than either. Splitting it says the same thing and stays checkable. */ + border-width: 5px; + border-style: solid; + border-color: transparent; + border-top-color: var(--ink); + pointer-events: none; +} + /* The week boundary the hen-day figure below compares across. One hairline, not a second fill: tinting the two weeks differently faded whichever week happened to hold fewer recorded days. */ .day-week { margin-left: 9px; - position: relative; } .day-week::before { diff --git a/web/src/styles.test.ts b/web/src/styles.test.ts index a6325afa0..f545375eb 100644 --- a/web/src/styles.test.ts +++ b/web/src/styles.test.ts @@ -260,7 +260,7 @@ describe("dashboard surfaces (#654, INV-8)", () => { // Any rule that APPLIES to a dashboard surface, not only one whose selector // starts with it: `.unrelated, .capture-tile:hover { … }` reaches the tile // just as surely, and an anchored match walked straight past it. - const TOUCHES = /(^|[\s,>+~])\.(capture-[a-z-]*|trend[a-z-]*|daystrip|day|day-week|stock-[a-z-]*|meter-stack|dash-list|panel-wide)\b/; + const TOUCHES = /(^|[\s,>+~])\.(capture-[a-z-]*|trend[a-z-]*|daystrip|day|day-week|tip|tipdock|avgline|stock-[a-z-]*|meter-stack|dash-list|panel-wide)\b/; const blocks = Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) .map((m) => ({ selector: m[1].trim(), body: m[2] })) .filter((b) => TOUCHES.test(b.selector)); @@ -272,7 +272,8 @@ describe("dashboard surfaces (#654, INV-8)", () => { it("declares the tile, cap link, day strip, stacked meter, ledger and list rules", () => { for (const s of [".capture-grid", ".capture-tile", ".capture-tile.is-missing", ".capture-tile.is-missing .capture-tile-eggs", ".capture-tile-eggs", ".capture-more", - ".trend-scale", ".daystrip", ".day", ".day > i", ".day-week", ".trend-rule", ".trend-kpi", + ".trend-scale", ".daystrip", ".day", ".day > i", ".day.on", ".day.on > i", ".day.on::after", + ".day-week", ".avgline", ".tipdock", ".tip", ".trend-rule", ".trend-kpi", ".meter-stack", ".meter-stack > span", ".stock-ledger", ".stock-ledger li", ".dash-list", ".dash-list li", ".panel-wide"]) bodyOf(s); expect(blocks.length).toBeGreaterThanOrEqual(20); From b0437acc1db21293e1fb7cb132726a38dbf8edd8 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 12 Sep 2026 18:01:18 +0000 Subject: [PATCH 2/8] fix(dashboard): outline the selected day instead of filling its slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approved mockup tinted the slot to 14% ink. On the running app that drew a full-height block, so a day with a 2% stub and a day with no bar at all both read as a bar carrying a real figure — the misreading this issue exists to remove. The outline alone marks the slot and the arrow points at it, and the bar still takes the ink fill where there is one. The average line moves in front of the bars; behind them it was invisible over exactly the days worth comparing against it. `tl` is now rendered rather than assumed: at the pinned right edge the readout is 163px in a 331px panel and is not clipped, but "Katamtaman" split the caption's title across two lines, so it takes the loanword this catalog already uses for Hen-day and Draft, and the caption wraps as a row rather than mid-title. --- web/src/components/DayStrip.tsx | 8 +++++--- web/src/i18n/tl.ts | 6 ++++-- web/src/styles.css | 25 ++++++++++++++++++------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/web/src/components/DayStrip.tsx b/web/src/components/DayStrip.tsx index 10701f71c..cb057413e 100644 --- a/web/src/components/DayStrip.tsx +++ b/web/src/components/DayStrip.tsx @@ -77,9 +77,6 @@ export function DayStrip({ data, label, title, peak, average, tip, from, to }: { aria-label={label} onMouseLeave={() => setActive(null)} > - {data.averagePct !== null && ( -