Skip to content

Production report cannot distinguish a day with no entry from a day that produced zero eggs #780

Closed

Description

@mforce

Split out of #777 (decision A), which could not fix this client-side.

What

ProductionDay cannot say whether a day was entered. ReportQueries.cs:157 walks

for (var d = from; d <= to; d = d.AddDays(1))

and emits a row for every calendar day in the window, with total = row?.Total ?? 0. FromCounts and Deaths default to 0 the same way, and HenDayPct is about birds rather than entries. So a day nobody recorded and a day that genuinely produced zero eggs arrive at the SPA identical, and no client can tell them apart.

The series being dense is correct and worth keeping — it is what makes the Dashboard's day strip a true calendar. The gap is only that a zero carries no provenance.

Why it matters now

#777 replaced the Dashboard's 14-day line with a strip of day slots, partly because a line drew a segment straight through unrecorded days and rendered them as a plateau of real production. The strip is honest about the shape but still asserts those days produced zero eggs, because that is all the API says. Two things are blocked on this:

  • the strip cannot distinguish "no entry yet" from "a zero day", though entryFor in web/src/lib/dashboard.ts already treats that as a distinct state for the capture tiles and renders it as an alarm;
  • the strip deliberately draws no average reference line. Averaging the recorded days asserts the flat days are absences; averaging the whole window asserts they are real zeros. A reference line that quietly picks a side is worse than no line, so Dashboard: Recent sales rows do not form columns, and the trend and stock charts are hard to read #777 shipped without one.

Shape of the fix

A presence signal on ProductionDay — an entry count for the day, or a flag. An entry count is probably better: it also distinguishes "some houses recorded" from "every house recorded", which is the same question the Today panel answers for the current day.

Decide before writing code:

  • The report record is a public contract. ProductionDay reaches the Reports page, the CSV exports and anything else reading /reports/production. Adding a field changes export columns; check every caller per AGENTS.md · A write-contract change must update its non-CI callers (Daily entry: require grading to reconcile sellable eggs before submit #394), and say what the export does with it.
  • Voided entries vacate their day (F17 follow-up: voided daily entry permanently blocks re-entering that day #82). A day whose only entries are voided is "no entry", not "entered with zero". entryFor already encodes that rule for the SPA; the server figure has to agree with it or the two surfaces will disagree about the same day.
  • Do not re-derive it in the SPA. The lib/dashboard.ts header note holds: pages never sum report rows, so this is a server figure or it is nothing.

Out of scope

No change to what the report measures, and no change to the strip's appearance beyond what the new signal makes possible.

Activity

  1. added
    bugSomething isn't working
    area:apiAPI/endpoint layer
    severity:p3Defect: degraded or partial behaviour
    on Sep 12, 2026
  2. mforce commented on Sep 12, 2026

    @mforce
    OwnerAuthor

    Important

    This design diverged during implementation. Four slot states rather than two, the hen-day
    denominator changed, and the arrow assertion below turned out to be false. The full table is in
    this comment; read it
    before planning against anything here. Left as written for history.

    Approved design

    Static mockups, not the running app. They use the real token values from the merged styles.css for both themes, captured at 1:1. Every geometric claim below was asserted in the harness rather than eyeballed.

    The chosen design

    Chosen design, light

    Chosen design, dark

    Six cases: a normal day, a recorded zero, an unrecorded day, both pinned edges, and the resting state.

    Tooltip docked above the strip. A reserved row that is always present, so the box appearing reflows nothing, and it covers neither the bars it describes nor the Avg/Peak figures the reader compares them against. It costs 2.2rem of panel height, permanently — the honest price, on a panel that already grew when the sparkline became a strip.

    An arrow pointing at the selected day. Anchored to the slot element, not positioned by index arithmetic. That was the first attempt and it drifted: the slots carry a 4px gap plus the 9px week break, so (i + 0.5) / n is not where a slot actually is. Anchored to the slot it cannot disagree with layout, whatever those gaps become later.

    Highlight: the bar changes fill, and the slot is outlined. Plain "change the bar's fill" cannot mark the two cases that matter most here — a day with a 2px stub, and a day with no bar at all. Five of the fourteen days in the fixture have no bar. So the slot takes an ink outline whether or not it holds a bar, and the bar takes the ink fill when there is one.

    Separator is a dash: Sep 3 – 822 eggs, Sep 9 – no entry.

    Asserted at every slot: the box stays inside the panel, the arrow sits under the box, the arrow is fully visible (5px arrow in an 8px gap) rather than swallowed by it, and the box never overlaps the strip.

    What the data change alone buys

    The entryCount change

    Sep 4 holds a submitted entry that recorded zero eggs; Sep 7–11 hold no entry. Today those six days are indistinguishable, because the chart decides "is there a bar" from totalEggs and the API returns zero for both. With EntryCount, Sep 4 keeps a stub at the baseline and Sep 7–11 stay genuinely empty.

    The average line is the mean of the recorded days only, labelled Avg beside Peak. That figure is only computable once this issue's field exists, which is why #781 deliberately shipped without it.

    Alternatives considered, and why they lost

    Highlight treatments

    Dimming every other bar links tooltip to bar most strongly but changes the whole chart on hover. Changing only the bar's fill is unmistakable on a normal day and has nothing to grab on a stub or an empty slot — on a 2px stub the ring reads as a rendering fault rather than a selection.

    Tooltip placement

    Placed inside the plot the box covers six neighbouring bars at the peak and four at the recorded zero. Measured, not estimated. It is reading a chart while covering it. Docked above it covers zero.

    Type design

    The current slot type is an optional-field bag and is the same defect class this issue exists to fix, one layer up:

    export interface DayStripSlot { date: string; heightPct: number; recorded: boolean; weekBreak: boolean }

    It permits an unrecorded day carrying a heightPct of 40. With EntryCount there are three genuine states, so they get a discriminant:

    export type DayStripSlot =
      | { kind: "unrecorded"; date: string; weekBreak: boolean }
      | { kind: "recorded"; date: string; eggs: number; heightPct: number; weekBreak: boolean };

    The renderer then narrows on kind with const _exhaustive: never in the default arm, so a fourth state fails to compile rather than silently rendering a bar. dayStrip(days, 7) becomes an object argument, and the tooltip's active state holds the slot rather than an index that can point outside the array.

    Noted and deliberately not done here: date: string should be a branded IsoDate validated at the API boundary — dates.ts already has isIsoCalendarDate doing the check, so the validator exists and the brand does not. It runs through ProductionDay, DailyEntry and FarmDate, so it is its own change with its own blast radius, not something to smuggle in here.

    Open before merge

    tl runs roughly 30% longer than en and none of the above is rendered in it. At a pinned edge on a 300px panel a translated Sep 11 – walang naitala may not fit. Per #688 nothing enforces this; only looking at it does.

    Scope

    Server: one g.Count() on the perDay aggregate that already runs, plus a field on the ProductionDay record. No new query, no index, no migration. GetProductionAsync has exactly one caller (ReportEndpoints), so no export gains a column and no #394 caller sweep is needed.

    OfficialStatuses is Submitted/Locked/ManagerAdjusted, so the count means submitted entries — deliberately stricter than the Dashboard's entryFor, which counts a Draft. A day holding only a Draft reads as unrecorded on the strip and as captured on the capture tiles. That is correct for a production figure and is written down rather than left to be discovered.

  3. mforce commented on Sep 12, 2026

    @mforce
    OwnerAuthor

    Amendment to the approved design

    The design above was approved and then diverged, mostly under adversarial review. Recording it here so this comment is not read as a description of what shipped. PR: #791.

    Approved Shipped Why
    Two slot states (unrecorded, recorded) Four: none, unrecorded, partial, recorded A day where some flocks filed and some did not has a total that is a floor, not a low day — drawing it like a complete day asserts a drop nobody recorded, which is this issue's defect one level in. And a day before the first placement owed no filing, so counting it as a gap made a new farm's first fortnight announce fourteen missing filings.
    Issue body: "no change to what the report measures" HenDayPct and PeriodHenDayPct divide by the exposure that reported The panel announced "N days have no entry" and printed, two lines below, a rate computed as if those days produced zero. On the dev farm: 42.3% before, 84.3% after. Widening authorised by the owner rather than assumed.
    "one g.Count() … no new query" Per-day aggregate regrouped to (Date, FlockId, HouseId) A count cannot say which flock filed, and both the rate's numerator and the house pairing need which. Still one query; no index, no migration.
    "the bar changes fill, and the slot is outlined" Outline only, and the ring moved outside the slot The fill drew a full-height block, so a 2px stub and an empty slot both read as bars carrying a real figure. Inset, the ring was invisible on the peak day, where .day.on > i had just painted the bar the ring's own colour.
    "the arrow is fully visible (5px arrow in an 8px gap) … asserted at every slot" The assertion was false. About 1px of the 5px wedge was showing, in the box's own colour A CSS triangle's wedge is its top border, so the 5px bottom border was invisible box pushing the wedge up behind the readout. The harness measured the gap, not the arrow. border-width: 5px 5px 0 now, and three guards — deleting the arrow's only visible colour had left all 67 style tests green.
    "Avg is the mean of the recorded days" Mean of the complete days A partial day's total would drag the reference down by however many flocks forgot.
    "the tooltip's active state holds the slot" Holds the date A snapshot went stale under a refetch: the readout showed a figure the same day's own accessible name had already replaced.
    Tab to each day One tab stop, then arrow keys Fourteen consecutive tab stops past a dashboard panel is a keyboard obstacle.

    Still open, deliberately

    RecordedFlocks counts a filing against the flock's own house. A flock that files from some other house has not answered for the house that owes the count, which is correct — but the report cannot detect a second house that a flock occupies and never files from, because Flock carries one HouseId. That is a modelling limit, not a reporting one.

    At a depletion boundary a flock can hold an entry for a date the bird ledger says it had ended. Its eggs stay in the day's egg total and out of the rate, so the rate cannot exceed what its own denominator supports. Recorded in GLOSSARY.md rather than left to be discovered.

    tl is rendered, not assumed: the readout at the pinned right edge is 163px in a 331px panel, unclipped, and the widest across all three locales is 262px.

  4. added 5 commits that reference this issue on Sep 12, 2026
    48c10e5
    de407c6
    0a4af6e
    d9e538f
    daa43b9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerbugSomething isn't workingseverity:p3Defect: degraded or partial behaviour

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions