Repository navigation
Description
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
entryForinweb/src/lib/dashboard.tsalready 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.
ProductionDayreaches the Reports page, the CSV exports and anything else reading/reports/production. Adding a field changes export columns; check every caller perAGENTS.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".
entryForalready 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.tsheader 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
fix(dashboard): give recent sales real columns and make both charts r…
mforce commentedon Sep 12, 2026
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.cssfor both themes, captured at 1:1. Every geometric claim below was asserted in the harness rather than eyeballed.The chosen design
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/Peakfigures 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) / nis 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
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
totalEggsand the API returns zero for both. WithEntryCount, 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
AvgbesidePeak. 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
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.
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:
It permits an unrecorded day carrying a
heightPctof 40. WithEntryCountthere are three genuine states, so they get a discriminant:The renderer then narrows on
kindwithconst _exhaustive: neverin 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: stringshould be a brandedIsoDatevalidated at the API boundary —dates.tsalready hasisIsoCalendarDatedoing the check, so the validator exists and the brand does not. It runs throughProductionDay,DailyEntryandFarmDate, so it is its own change with its own blast radius, not something to smuggle in here.Open before merge
tlruns roughly 30% longer thanenand none of the above is rendered in it. At a pinned edge on a 300px panel a translatedSep 11 – walang naitalamay not fit. Per #688 nothing enforces this; only looking at it does.Scope
Server: one
g.Count()on theperDayaggregate that already runs, plus a field on theProductionDayrecord. No new query, no index, no migration.GetProductionAsynchas exactly one caller (ReportEndpoints), so no export gains a column and no #394 caller sweep is needed.OfficialStatusesis Submitted/Locked/ManagerAdjusted, so the count means submitted entries — deliberately stricter than the Dashboard'sentryFor, 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.mforce commentedon Sep 12, 2026
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.
unrecorded,recorded)none,unrecorded,partial,recordedHenDayPctandPeriodHenDayPctdivide by the exposure that reportedg.Count()… no new query"(Date, FlockId, HouseId).day.on > ihad just painted the bar the ring's own colour.border-width: 5px 5px 0now, and three guards — deleting the arrow's only visible colour had left all 67 style tests green.Avgis the mean of the recorded days"Still open, deliberately
RecordedFlockscounts 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, becauseFlockcarries oneHouseId. 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.mdrather than left to be discovered.tlis 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.fix(reports): drop the house pairing and rate only flocks that have b…
fix(reports): decide completeness from which flocks filed, not how many
fix(dashboard): tell a day with no entry from a day that laid no eggs (…
feat(sim): seed a second farm for the README dashboard capture (#867)
feat(api): scope the production report to one flock, server-side (#916)
feat(api): scope the production report to one flock, server-side (#916)
feat(api): scope the production report to one flock, server-side (#916)