diff --git a/specs/product/GLOSSARY.md b/specs/product/GLOSSARY.md index 02a679441..4ea200b85 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -112,7 +112,7 @@ does not count (it vacated its day, #82); a Draft does (captured, not yet submitted). Depleted and archived flocks appear only when they do have an entry today. Beside it the dashboard shows the last 14 days from the **Production report** (submitted days only, yesterday back — a fixed window, -unlike Reports) with the report's own **Hen-day %** for the last 7 complete +unlike Reports) with the report's own **Hen-day %** for the last 7 finished days against the 7 before, and stock as one stacked bar by grade whose total is the Stock screen's. @@ -700,11 +700,44 @@ the admin set on this screen, so clearing it would silently widen the page from one record to the whole farm. The empty state stays a plain sentence with no action (#655). -**Hen-day % (#91)** — eggs collected ÷ hen-days × 100 (spec §19.3). A -hen-day is one bird alive for one day; the day's bird count comes from the -bird ledger (placements + movements). The production report shows it per -day and for the whole period (period eggs ÷ period hen-days — not an -average of daily percentages). +**Hen-day % (#91, #780)** — eggs collected ÷ **recorded** hen-days × 100 +(spec §19.3, amended by #780). A hen-day is one bird alive for one day; the +day's bird count comes from the bird ledger (placements + movements). The +production report shows it per day and for the whole period (period eggs ÷ +period recorded hen-days — not an average of daily percentages). + +The denominator counts only the houses that **recorded** the day (#780). A +house that filed nothing produced no eggs the report can see, so counting +its birds as exposure states a lay rate nothing supports: a day nobody +recorded read as 0%, and a day one house of three missed read as a collapse +in output. The **Hen-days** column keeps its full meaning — every bird +alive, recorded or not — and the gap between it and the recorded exposure +is exactly what is missing. A day nobody recorded has **no** percentage, +not 0. + +A flock files against a date, and the bird ledger decides separately whether +that flock was live on it and how many birds it held. The two disagree at a +lifecycle boundary — an entry filed on the day after depletion, a placement +date corrected forward past entries that already exist, a mistyped mortality +that removes more birds than were placed. **The rate's numerator is +restricted to exactly the flocks whose birds are in its denominator**, so a +flock in any of those states keeps its eggs in the day's egg total and out +of the rate. Without that restriction the two exclusions compounded and a +farm laying 80% was reported at 160%. + +The report therefore carries **Rated eggs** beside **Recorded hen-days** — +the percentage's own numerator and denominator — so the figure can be +reproduced rather than taken on trust. They equal the day's eggs and its +hen-days on any ordinary day; a gap means a filing and the bird ledger +disagree about that date. + +**Partly recorded day (#780)** — a date where at least one flock filed an +official entry and at least one did not. Its egg total is a **floor**, not +the day's output, so the Dashboard's 14-day strip draws it hatched and +leaves it out of that panel's Peak and Avg; hen-day % still rates it on the +flocks that did file, so a missing flock lowers what the figure is measured +over rather than the figure itself. Distinct from a day nobody recorded, +which has no figure at all. **Production report (#91)** — per-day official production over a range (Draft entries aren't submitted, Voided ones vacated their day — neither diff --git a/specs/product/specs.md b/specs/product/specs.md index 7b4c612a3..ab05ce136 100644 --- a/specs/product/specs.md +++ b/specs/product/specs.md @@ -2230,12 +2230,28 @@ sum(bird_inventory_movements.quantity_delta) ```text Hen-day production % (single day) = -eggs collected on date / hen-days on date × 100 +eggs collected on date / RECORDED hen-days on date × 100 Hen-day production % (period) = -total eggs collected in period / sum(daily hen-days in period) × 100 +total eggs collected in period / sum(daily RECORDED hen-days in period) × 100 ``` +**Recorded hen-days (#780).** The denominator counts only the flocks that filed +an official entry for that date. The numerator can only ever contain eggs from +flocks that filed, so counting a silent flock's birds as exposure reports a lay +rate nothing supports: before #780 a day nobody recorded read as 0% production, +and a day one flock of three missed read as a collapse in output. A date with no +official entry has **no** hen-day % at all — not 0. + +The numerator is restricted to the same flocks. A flock outside its lifecycle +window, or one whose removals have over-run its placement, has eggs the report +shows and birds it does not count; admitting its eggs against a denominator it +is absent from reported 160% on a farm laying 80%. Both halves of the fraction +are carried in the payload so it can be reproduced. `hen-days on date` below +keeps its full meaning (every live laying bird, recorded or not) and is what the +report's own Hen-days column shows; the gap between the two is exactly what is +missing. → [`docs/decisions/`](../../docs/decisions/) and issue #780. + `hen-days on date` is the live laying-bird count **on that operational date**, reconstructed from the bird ledger, not the current count: @@ -2259,8 +2275,8 @@ Rules: - Flag hen-day % as **approximate** for `mixed` flocks, and reserve a future `female_bird_count` (or sex-split placement movements) to make it exact. Do not silently report an exact-looking hen-day % for a mixed flock. -- Period rate divides total eggs by the sum of daily hen-days, not by an end-of-period - headcount. +- Period rate divides total eggs by the sum of daily **recorded** hen-days, not by an + end-of-period headcount and not by the calendar's full exposure (#780). - The benchmark-meaningful period anchors to **start of lay** (`flock.expected_start_lay_date`, or first laying production date), not an arbitrary calendar range. Comparing week 1 of lay against week 20 as one blended rate produces a number that matches no industry diff --git a/src/Cluckwork.Application/Features/Reports/IReportQueries.cs b/src/Cluckwork.Application/Features/Reports/IReportQueries.cs index 6400ad342..71ee0ed5d 100644 --- a/src/Cluckwork.Application/Features/Reports/IReportQueries.cs +++ b/src/Cluckwork.Application/Features/Reports/IReportQueries.cs @@ -25,16 +25,58 @@ 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. +// #780 — three fields exist so a consumer can tell what the farm KNOWS from +// what the farm PRODUCED. Every other figure here is 0 both for a day nobody +// recorded and for a day that genuinely produced no eggs, so without them the +// two are indistinguishable, which is what left the Dashboard's 14-day strip +// asserting a zero it had no evidence for. +// +// `RecordedFlocks` — flocks that filed an OFFICIAL entry (Submitted, Locked, +// ManagerAdjusted). 0 means nobody recorded the day; it does not mean the +// farm produced nothing. Counted from the entries, so a flock that filed +// counts whether or not the bird ledger agrees it was live that day. +// `ExpectedFlocks` — flocks that owed one: placed, not yet depleted or +// archived. It and `RecordedFlocks` can legitimately disagree EITHER WAY at a +// lifecycle boundary — a flock files on a day the ledger says it had ended, or +// a placement date is corrected forward past entries that already exist — so +// never assume one bounds the other. +// `MissingFlocks` — expected flocks with no filing, and the ONLY sound test for +// a partly recorded day. Comparing the two counts above is not: they are +// counts over different sets, so expected {A, B} against filings {A, C} gives +// 2 and 2 and hides B entirely, presenting a day short one flock's eggs as +// complete and feeding it into the Dashboard's Peak and Avg. `MissingFlocks` +// is computed from identities. `ExpectedFlocks - MissingFlocks` is how many +// of the flocks that owed a count actually filed one. +// `RecordedHenDays` — the exposure behind the rate. `HenDays` keeps the +// glossary's meaning (one bird alive for one day, over every flock) so the +// Reports column still says what it always said; the rate divides by the +// subset with evidence behind it. Dividing by `HenDays` is what made an +// unrecorded day read as a day of zero production, and did the same to a day +// one flock of three missed. +// `RatedEggs` — `HenDayPct`'s NUMERATOR: the eggs of exactly the flocks whose +// birds are in `RecordedHenDays`. Equal to `TotalEggs` on any day whose +// filings all come from live flocks with birds, which is every ordinary day. +// It is carried rather than left implicit so the rate is reproducible from +// the payload; without it, `TotalEggs / RecordedHenDays` silently disagrees +// with the percentage beside it on exactly the contradictory days. 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 RecordedFlocks, int ExpectedFlocks, int MissingFlocks, + long HenDays, long RecordedHenDays, int RatedEggs, decimal? HenDayPct); public sealed record GradeTotal(Guid EggGradeId, string Name, int Quantity); +// `TotalRecordedHenDays` and `TotalRatedEggs` are `PeriodHenDayPct`'s exact +// denominator and numerator (#780), carried so the period figure is +// reproducible from the payload rather than being a number nobody can check. +// `TotalRecordedHenDays` equals `TotalHenDays` on a period every flock +// recorded, and the gap between them is exactly what is missing. public sealed record ProductionReport( IReadOnlyList Days, int TotalEggs, int TotalSellable, int TotalFromCounts, int TotalDeaths, - long TotalHenDays, decimal? PeriodHenDayPct, + long TotalHenDays, long TotalRecordedHenDays, int TotalRatedEggs, + decimal? PeriodHenDayPct, IReadOnlyList GradeTotals); // About the period's ORDERS (order date in range): revenue is their confirmed diff --git a/src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs b/src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs index 20b8033f1..357529dac 100644 --- a/src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs +++ b/src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs @@ -15,12 +15,29 @@ public sealed class ReportQueries(AppDbContext db) : IReportQueries public async Task GetProductionAsync( DateOnly from, DateOnly to, CancellationToken ct = default) { - var perDay = await db.DailyEntries + // Grouped by (date, flock, HOUSE), not by date (#780). Three figures + // below need to know WHICH unit filed rather than how many did, and two + // of them were wrong when this only knew the count: + // + // - the lay rate's numerator has to come from the same flocks as its + // denominator, or a flock whose birds are absent from the exposure + // still puts eggs in the rate. A depletion backdated after an entry + // was filed did exactly that, turning a correct 80% into 160%; + // - "did every flock file" is a question about the flock at ITS OWN + // house, because the daily entry's natural key is + // (Account, Farm, House, Flock, Date). Counting distinct flocks let + // a flock that filed from some other house satisfy the expectation. + // + // Bounded by (days × flocks × houses) over a range the endpoint already + // caps at 366 days, so this is the same order as the day loop below. + var perUnit = await db.DailyEntries .Where(e => e.Date >= from && e.Date <= to && OfficialStatuses.Contains(e.Status)) - .GroupBy(e => e.Date) + .GroupBy(e => new { e.Date, e.FlockId, e.HouseId }) .Select(g => new { - Date = g.Key, + g.Key.Date, + g.Key.FlockId, + g.Key.HouseId, Total = g.Sum(e => e.TotalEggs), Cracked = g.Sum(e => e.CrackedEggs), Dirty = g.Sum(e => e.DirtyEggs), @@ -35,7 +52,16 @@ public async Task GetProductionAsync( + (e.DirtyGradeId != null ? e.DirtyEggs : 0)), Deaths = g.Sum(e => e.MortalityCount), }) - .ToDictionaryAsync(x => x.Date, ct); + .ToListAsync(ct); + + // 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 the row rests on, so a day holding + // only a Draft reads as unrecorded here and as captured on the tiles. + var unitsByDay = perUnit + .GroupBy(x => x.Date) + .ToDictionary(g => g.Key, g => g.ToList()); // Period grade totals (per-day × per-grade would bloat the payload). var gradeTotals = await db.DailyEntryGrades @@ -118,6 +144,10 @@ public async Task GetProductionAsync( .Select(f => new { f.Id, + // #780 — the house this flock owes its count from. A daily + // entry's natural key names a house, so "did this flock file" + // is only answerable against the pair. + f.HouseId, f.PlacementDate, f.InitialCount, f.DepletedOn, @@ -154,31 +184,120 @@ public async Task GetProductionAsync( var flockCounts = flocks.ToDictionary( f => f.Id, f => f.InitialCount - openingRemovals.GetValueOrDefault(f.Id)); + // Eggs that the lay rate is allowed to divide. Accumulated beside the + // period totals because it must come from the same flock set as + // `totalRecordedHenDays`, and nothing downstream can reconstruct it. + var totalRatedEggs = 0; for (var d = from; d <= to; d = d.AddDays(1)) { + var units = unitsByDay.GetValueOrDefault(d) ?? []; + // Which FLOCKS filed, not which (flock, house) pairs. Matching the + // pair was tried and reverted: `RecordDailyEntryHandler` says houses + // "aren't aggregates yet — phantom ids until Phase 2's House model", + // and declines to check the entry's house against the flock's, while + // `CreateFlockHandler` gives every flock the same + // `SeedDefaults.HouseId`. So the rule could never fire on real data + // and rested entirely on an unvalidated caller-supplied field — a + // client sending any other id turned a submitted day into "no + // entry". Pair it when houses are real and the write path checks it. + var filed = units.Select(u => u.FlockId).ToHashSet(); + long henDays = 0; + // The exposure the lay rate is actually computed over: only the + // flocks that filed. A flock that filed nothing produced no eggs as + // far as this report knows, so its birds must not sit in the + // denominator — that is what made an unrecorded day read as a day of + // zero production (#780), and it does the same to a day where one + // flock of three forgot. + long recordedHenDays = 0; + var expectedFlocks = 0; + // Expected flocks that did NOT file. Completeness cannot be a + // comparison of the two COUNTS: since a flock that filed counts + // whether or not the ledger says it was live, expected {A, B} with + // filings {A, C} gives 2 and 2 — equal counts over different sets, + // so B's missing filing reads as a complete day and its shortfall + // enters Peak and Avg. Comparing identities is the only thing that + // answers "did everyone who owed a count file one". + var missingFlocks = 0; + // The flocks whose birds ARE in the denominator. The numerator is + // then taken from exactly these, so the two cannot disagree. + var rated = new HashSet(); foreach (var f in flocks) { var ended = (f.DepletedOn is { } dep && d > dep) || (f.ArchivedOn is { } arc && d > arc); if (f.PlacementDate <= d && !ended) - henDays += Math.Max(flockCounts[f.Id], 0); + { + var birds = Math.Max(flockCounts[f.Id], 0); + henDays += birds; + expectedFlocks++; + // `birds > 0` is load-bearing, not defensive. A flock whose + // removals have over-run its placement — a mistyped + // mortality, which nothing on the write path bounds — + // contributes no exposure, so admitting its eggs to the + // numerator divides by a denominator they are not in. That + // reported 160% on a farm laying 80%. + if (filed.Contains(f.Id)) + { + // `birds > 0` is load-bearing for the RATE, not for + // whether the flock filed: a flock with no birds still + // answered for itself, it just contributes no exposure. + if (birds > 0) + { + recordedHenDays += birds; + rated.Add(f.Id); + } + } + else + { + missingFlocks++; + } + } var todaysRemovals = removalsByFlockDay.GetValueOrDefault(f.Id)?.GetValueOrDefault(d) ?? 0L; flockCounts[f.Id] -= todaysRemovals; } - var row = perDay.GetValueOrDefault(d); - var total = row?.Total ?? 0; - var sellable = total - (row?.Cracked ?? 0) - (row?.Dirty ?? 0) - (row?.Discarded ?? 0); + // Counted from the entries, NOT from the lifecycle walk above: a + // flock that filed has filed, whether or not the bird ledger agrees + // it was live. Deriving it from the walk made `recordedFlocks <= + // expectedFlocks` hold by construction, so a placement date + // corrected forward past its own entries collapsed the day to + // (0, 0) — and the strip then drew a day with real submitted eggs + // as "no flocks". `isComplete` uses `>=` precisely because these + // two can legitimately disagree. + var recordedFlocks = filed.Count; + + // Every official egg of the day, whatever filed it — the report's + // egg column must not silently drop a row. + var total = units.Sum(u => u.Total); + var cracked = units.Sum(u => u.Cracked); + var dirty = units.Sum(u => u.Dirty); + var discarded = units.Sum(u => u.Discarded); + var sellable = total - cracked - dirty - discarded; + // The rate's numerator, restricted to the flocks whose birds are in + // its denominator. A flock outside its own lifecycle window — a + // depletion backdated after its entry was filed — has eggs here and + // no birds there, and dividing one by the other reported 160% on + // data the previous code rated correctly at 80%. Its eggs stay in + // `total` above and out of the rate. + var ratedEggs = units.Where(u => rated.Contains(u.FlockId)).Sum(u => u.Total); + totalRatedEggs += ratedEggs; + days.Add(new ProductionDay( - d, total, row?.Cracked ?? 0, row?.Dirty ?? 0, row?.Discarded ?? 0, - sellable, row?.FromCounts ?? 0, row?.Deaths ?? 0, henDays, - henDays > 0 ? Math.Round(total * 100m / henDays, 1) : null)); + d, total, cracked, dirty, discarded, + sellable, units.Sum(u => u.FromCounts), units.Sum(u => u.Deaths), + recordedFlocks, expectedFlocks, missingFlocks, + henDays, recordedHenDays, ratedEggs, + // Over the exposure that filed, never over the calendar. null, + // not 0, when nothing filed — a day with no evidence has no lay + // rate, and printing 0.0% is the claim #780 removed. + recordedHenDays > 0 ? Math.Round(ratedEggs * 100m / recordedHenDays, 1) : null)); if (d == DateOnly.MaxValue) break; // AddDays would overflow } var totalEggs = days.Sum(x => x.TotalEggs); var totalHenDays = days.Sum(x => x.HenDays); + var totalRecordedHenDays = days.Sum(x => x.RecordedHenDays); return new ProductionReport( days, totalEggs, @@ -186,7 +305,12 @@ public async Task GetProductionAsync( days.Sum(x => x.FromCounts), days.Sum(x => x.Deaths), totalHenDays, - totalHenDays > 0 ? Math.Round(totalEggs * 100m / totalHenDays, 1) : null, + totalRecordedHenDays, + totalRatedEggs, + // Same numerator and denominator rule as the per-day figure, so the + // period total and the rows it summarises cannot tell different + // stories — and so the period cannot exceed 100% where no row does. + totalRecordedHenDays > 0 ? Math.Round(totalRatedEggs * 100m / totalRecordedHenDays, 1) : null, combined .Select(t => new GradeTotal( t.Key, gradeNames.GetValueOrDefault(t.Key, "?"), t.Value)) diff --git a/tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs b/tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs index 2c2544919..8f7fe465b 100644 --- a/tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs @@ -12,11 +12,14 @@ 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 RecordedFlocks, int ExpectedFlocks, int MissingFlocks, + long HenDays, long RecordedHenDays, int RatedEggs, decimal? HenDayPct); private sealed record GradeRow(Guid EggGradeId, string Name, int Quantity); private sealed record ProductionDto( List Days, int TotalEggs, int TotalSellable, int TotalFromCounts, - int TotalDeaths, long TotalHenDays, decimal? PeriodHenDayPct, List GradeTotals); + int TotalDeaths, long TotalHenDays, long TotalRecordedHenDays, + int TotalRatedEggs, decimal? PeriodHenDayPct, List GradeTotals); private sealed record SalesDto( int ConfirmedCount, long RevenueMinorUnits, long PaidMinorUnits, long OutstandingMinorUnits, int VoidedCount, string CurrencyCode, int CurrencyMinorUnit); @@ -115,7 +118,8 @@ public async Task Production_HenDays_OfficialEntriesOnly() var farmId = Guid.NewGuid(); var grades = await factory.SeedEggGradesAsync(accountId, farmId, "Large"); // Seeded flock: 100 birds placed well before the range. - var flockId = await factory.SeedFlockAsync(accountId, farmId); + var houseId = Guid.NewGuid(); + var flockId = await factory.SeedFlockAsync(accountId, farmId, houseId); var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); async Task RecordAsync(DateOnly date, int total, int mortality, bool submit) @@ -127,7 +131,7 @@ async Task RecordAsync(DateOnly date, int total, int mortality, bool submi var response = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new { farmId, - houseId = Guid.NewGuid(), + houseId, flockId, date, totalEggs = total, @@ -167,13 +171,450 @@ async Task RecordAsync(DateOnly date, int total, int mortality, bool submi Assert.Equal(98, day2.HenDays); // yesterday's 2 deaths bite today Assert.Equal(170, report.TotalEggs); Assert.Equal(198, report.TotalHenDays); - // Period % = 170/198 — not the average of daily percentages. + // Period % = 170/198 — not the average of daily percentages. Every day + // here is recorded, so recorded hen-days equal total hen-days and this + // assertion would survive a revert of #780's denominator; the guard for + // that is Production_RecordedFlocks_... below, not this one. Assert.Equal(85.9m, report.PeriodHenDayPct); // #394: each submitted day is now graded exactly to its own sellable // count (77 + 87), not the old arbitrary total-10 stand-in. 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_RecordedFlocks_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"); + // The flock's own house, and the entries are filed from it: since #780 a + // filing only answers for the house that owes the count. + var houseId = Guid.NewGuid(); + var flockId = await factory.SeedFlockAsync(accountId, farmId, houseId); + 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, + 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.RecordedFlocks)); + // 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.RecordedFlocks); + Assert.Equal(0, missing.RecordedFlocks); + // And the Draft sits on the unrecorded side, with its 90 eggs invisible. + Assert.Equal((0, 0), (draftOnly.TotalEggs, draftOnly.RecordedFlocks)); + + // The lay rate follows the evidence, not the calendar. The house was + // alive on all four days, so `HenDays` is 100 throughout and the Reports + // column keeps the meaning the glossary gives it; what changes is the + // denominator the RATE divides by. + Assert.Equal((100L, 100L), (laid.HenDays, laid.RecordedHenDays)); + Assert.Equal(60m, laid.HenDayPct); + // Recorded and genuinely zero: exposure counted, rate is a real 0%. + Assert.Equal((100L, 100L), (zero.HenDays, zero.RecordedHenDays)); + Assert.Equal(0m, zero.HenDayPct); + // Nobody reported: birds alive, but no exposure with evidence behind it, + // so there is no rate at all. Printing 0.0% here was the conflation. + Assert.Equal((100L, 0L), (missing.HenDays, missing.RecordedHenDays)); + Assert.Null(missing.HenDayPct); + Assert.Null(draftOnly.HenDayPct); + + // Period: 60 eggs over the 200 hen-days that reported = 30%, not the + // 15% that dividing by all 400 calendar hen-days would give. + Assert.Equal(400L, report.TotalHenDays); + Assert.Equal(200L, report.TotalRecordedHenDays); + Assert.Equal(30m, report.PeriodHenDayPct); + } + + // #780 — a day where SOME houses reported is not a day of low production + // and not a day of no production; it is a day whose total is a floor. The + // count of houses that owed a filing is what makes it visible, and the lay + // rate must divide only by the houses that filed or a missing house reads + // as a collapse in output. + [Fact] + public async Task Production_PartiallyRecordedDay_IsCountedAndRatedOnTheHousesThatReported() + { + 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"); + // Two flocks in two houses, 100 birds each, both placed well before the + // window. Each files from its own house, which is what a filing has to + // do to answer for that house (#780). + var houseAId = Guid.NewGuid(); + var houseBId = Guid.NewGuid(); + var houseA = await factory.SeedFlockAsync(accountId, farmId, houseAId); + var houseB = await factory.SeedFlockAsync(accountId, farmId, houseBId); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + + async Task RecordAsync(Guid flockId, DateOnly date, int total) + { + var response = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, + houseId = flockId == houseA ? houseAId : houseBId, + flockId, + date, + totalEggs = total, + crackedEggs = 0, + dirtyEggs = 0, + discardedEggs = 0, + mortalityCount = 0, + grades = new[] { new { eggGradeId = grades["Large"], quantity = total } } + }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var id = (await response.Content.ReadFromJsonAsync())!.Id; + Assert.Equal(HttpStatusCode.OK, (await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString())).StatusCode); + } + + // Day -2: both houses report 80 each. Day -1: only house A does. + await RecordAsync(houseA, Today.AddDays(-2), 80); + await RecordAsync(houseB, Today.AddDays(-2), 80); + await RecordAsync(houseA, Today.AddDays(-1), 80); + + var report = await client.GetFromJsonAsync( + $"/api/v1/reports/production?from={Today.AddDays(-2):yyyy-MM-dd}&to={Today.AddDays(-1):yyyy-MM-dd}"); + + var (full, partial) = (report!.Days[0], report.Days[1]); + + Assert.Equal((2, 2), (full.RecordedFlocks, full.ExpectedFlocks)); + // The whole point: one of two houses filed, and the row says so. + Assert.Equal((1, 2), (partial.RecordedFlocks, partial.ExpectedFlocks)); + + // Both houses were alive on both days, so the exposure is 200 either + // way; only the reported half of it carries evidence on the second day. + Assert.Equal((200L, 200L), (full.HenDays, full.RecordedHenDays)); + Assert.Equal((200L, 100L), (partial.HenDays, partial.RecordedHenDays)); + + // 80% on both days. Dividing the partial day's 80 eggs by all 200 + // hen-days would report 40% and show a healthy farm halving its output + // overnight, when what actually happened is that somebody did not file. + Assert.Equal(80m, full.HenDayPct); + Assert.Equal(80m, partial.HenDayPct); + Assert.Equal(80m, report.PeriodHenDayPct); + } + + // #780 — the lay rate's numerator must come from the same flocks as its + // denominator. Depletion is routinely recorded after the fact, so a flock + // can hold an official entry for a date the bird ledger later says it had + // already ended: its eggs are in the day's total, its birds are in no + // exposure. Dividing one by the other reported 160% on data the pre-#780 + // code rated at a correct 80%. + [Fact] + public async Task Production_BackdatedDepletion_LeavesItsEggsOutOfTheRateRatherThanOver100() + { + 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 staying = await factory.SeedFlockAsync(accountId, farmId); + var leaving = await factory.SeedFlockAsync(accountId, farmId); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + var day = Today.AddDays(-1); + + async Task RecordAsync(Guid flockId) + { + var house = await factory.WithTenantScopeAsync(accountId, async db => + (await db.Flocks.FirstAsync(f => f.Id == flockId)).HouseId); + var response = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, houseId = house, flockId, date = day, + totalEggs = 80, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, mortalityCount = 0, + grades = new[] { new { eggGradeId = grades["Large"], quantity = 80 } } + }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var id = (await response.Content.ReadFromJsonAsync())!.Id; + Assert.Equal(HttpStatusCode.OK, (await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString())).StatusCode); + } + + await RecordAsync(staying); + await RecordAsync(leaving); + // The depletion lands afterwards and is backdated before the entry — + // the write path refuses an entry against an already-depleted flock, so + // this order is the only way in, and it is how a farm records it. + await factory.WithTenantScopeAsync(accountId, async db => + { + var f = await db.Flocks.FirstAsync(x => x.Id == leaving); + f.Deplete(day.AddDays(-1)); + await db.SaveChangesAsync(); + }); + + var report = await client.GetFromJsonAsync( + $"/api/v1/reports/production?from={day:yyyy-MM-dd}&to={day:yyyy-MM-dd}"); + var row = report!.Days.Single(); + + // Both flocks' eggs stay in the day's total — the egg column may not + // silently drop a filed row. + Assert.Equal(160, row.TotalEggs); + // Only the live flock is exposure, and only its eggs are rated. + Assert.Equal((100L, 80), (row.RecordedHenDays, row.RatedEggs)); + Assert.Equal(80m, row.HenDayPct); + Assert.Equal(80m, report.PeriodHenDayPct); + Assert.True(row.HenDayPct <= 100m, "a lay rate over 100% means the numerator outran its own denominator"); + } + + // #780 — a filing counts as a filing whatever house id it carries. Matching + // the entry's house against the flock's was tried and reverted: houses are + // not aggregates yet (`RecordDailyEntryHandler` calls them "phantom ids + // until Phase 2's House model" and declines to check them), every flock is + // created with the same `SeedDefaults.HouseId`, so the rule could not fire + // on real data while a client sending any other id turned a submitted day + // into "no entry". This pins the revert so it is not re-introduced without + // the write-path check it needs. + [Fact] + public async Task Production_EntryWithAnUnrelatedHouseId_StillCountsAsThatFlocksFiling() + { + 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)); + var day = Today.AddDays(-1); + + var response = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, houseId = Guid.NewGuid(), flockId, date = day, + totalEggs = 40, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, mortalityCount = 0, + grades = new[] { new { eggGradeId = grades["Large"], quantity = 40 } } + }); + var id = (await response.Content.ReadFromJsonAsync())!.Id; + await client.PostWithKeyAsync($"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString()); + + var report = await client.GetFromJsonAsync( + $"/api/v1/reports/production?from={day:yyyy-MM-dd}&to={day:yyyy-MM-dd}"); + var row = report!.Days.Single(); + + Assert.Equal((40, 1, 1), (row.TotalEggs, row.RecordedFlocks, row.ExpectedFlocks)); + Assert.Equal(40m, row.HenDayPct); + } + + // #780 — a placement date corrected forward past entries that already exist + // leaves a day whose filings all come from flocks the ledger says were not + // yet placed. Deriving RecordedFlocks from the lifecycle walk made + // `recorded <= expected` hold by construction, so that day collapsed to + // (0, 0) and the strip drew real submitted eggs as "no flocks". + [Fact] + public async Task Production_PlacementCorrectedPastItsEntries_StillReportsThemAsFiled() + { + 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)); + var day = Today.AddDays(-3); + + var response = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, houseId = Guid.NewGuid(), flockId, date = day, + totalEggs = 80, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, mortalityCount = 0, + grades = new[] { new { eggGradeId = grades["Large"], quantity = 80 } } + }); + var id = (await response.Content.ReadFromJsonAsync())!.Id; + await client.PostWithKeyAsync($"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString()); + + // The typo correction: placement moves to AFTER the day that was filed. + await factory.WithTenantScopeAsync(accountId, async db => + { + var f = await db.Flocks.FirstAsync(x => x.Id == flockId); + f.Update(f.Name, f.Breed, Today.AddDays(-1), f.InitialCount); + await db.SaveChangesAsync(); + }); + + var report = await client.GetFromJsonAsync( + $"/api/v1/reports/production?from={day:yyyy-MM-dd}&to={day:yyyy-MM-dd}"); + var row = report!.Days.Single(); + + // The eggs were submitted and the day says so, however the ledger now + // reads. No flock was live, so there is no exposure and no rate. + Assert.Equal(80, row.TotalEggs); + Assert.Equal((1, 0), (row.RecordedFlocks, row.ExpectedFlocks)); + Assert.Equal((0L, 0), (row.RecordedHenDays, row.RatedEggs)); + Assert.Null(row.HenDayPct); + } + + // #780 — nothing on the write path bounds a mortality against the flock's + // own count, so an over-removed flock has eggs and no birds. Admitting its + // eggs to a numerator whose denominator excludes it reported 160% on a farm + // laying 80%. + [Fact] + public async Task Production_OverRemovedFlock_LeavesItsEggsOutOfTheRate() + { + 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 wiped = await factory.SeedFlockAsync(accountId, farmId); + var healthy = await factory.SeedFlockAsync(accountId, farmId); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + + async Task RecordAsync(Guid flockId, DateOnly date, int total, int mortality) + { + 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 = mortality, + grades = total == 0 ? Array.Empty() : [new { eggGradeId = grades["Large"], quantity = total }] + }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var id = (await response.Content.ReadFromJsonAsync())!.Id; + Assert.Equal(HttpStatusCode.OK, (await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString())).StatusCode); + } + + // A mistyped mortality removes ten times the flock's 100 birds. + await RecordAsync(wiped, Today.AddDays(-6), 0, 1000); + await RecordAsync(wiped, Today.AddDays(-1), 80, 0); + await RecordAsync(healthy, Today.AddDays(-1), 80, 0); + + var report = await client.GetFromJsonAsync( + $"/api/v1/reports/production?from={Today.AddDays(-1):yyyy-MM-dd}&to={Today.AddDays(-1):yyyy-MM-dd}"); + var row = report!.Days.Single(); + + Assert.Equal(160, row.TotalEggs); + // Only the flock with birds is exposure, and only its eggs are rated. + Assert.Equal((100L, 80), (row.RecordedHenDays, row.RatedEggs)); + Assert.Equal(80m, row.HenDayPct); + Assert.True(row.HenDayPct <= 100m, "a lay rate over 100% means the numerator outran its own denominator"); + // The payload carries both halves, so the figure is reproducible. + Assert.Equal( + Math.Round(row.RatedEggs * 100m / row.RecordedHenDays, 1), + row.HenDayPct); + } + + // #780, found by an external review — completeness cannot be a comparison of + // RecordedFlocks against ExpectedFlocks, because they count different SETS. + // A flock that files outside its own lifecycle window counts as having + // filed and answers nobody's expectation, so two expected flocks against + // filings from one of them plus an outsider gives 2 and 2 — and the flock + // that never filed vanishes, its shortfall presented as a complete day. + [Fact] + public async Task Production_EqualFlockCountsOverDifferentSets_StillReportsTheMissingFiling() + { + 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 filed = await factory.SeedFlockAsync(accountId, farmId); // expected, and files + var silent = await factory.SeedFlockAsync(accountId, farmId); // expected, files nothing + var outsider = await factory.SeedFlockAsync(accountId, farmId); // files, then leaves + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + var day = Today.AddDays(-1); + + async Task RecordAsync(Guid flockId, int total) + { + var response = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new + { + farmId, houseId = Guid.NewGuid(), flockId, date = day, + totalEggs = total, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, mortalityCount = 0, + grades = new[] { new { eggGradeId = grades["Large"], quantity = total } } + }); + var id = (await response.Content.ReadFromJsonAsync())!.Id; + await client.PostWithKeyAsync($"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString()); + } + + await RecordAsync(filed, 80); + await RecordAsync(outsider, 80); + // The outsider's depletion is backdated behind its own filing, so it is + // no longer expected on that date while still having filed on it. + await factory.WithTenantScopeAsync(accountId, async db => + { + var f = await db.Flocks.FirstAsync(x => x.Id == outsider); + f.Deplete(day.AddDays(-1)); + await db.SaveChangesAsync(); + }); + + var report = await client.GetFromJsonAsync( + $"/api/v1/reports/production?from={day:yyyy-MM-dd}&to={day:yyyy-MM-dd}"); + var row = report!.Days.Single(); + + // The trap: two filings, two expected flocks. Equal counts, different sets. + Assert.Equal((2, 2), (row.RecordedFlocks, row.ExpectedFlocks)); + // And the flock that never filed is still visible. + Assert.Equal(1, row.MissingFlocks); + Assert.Equal(160, row.TotalEggs); + // Only the live flock that filed is exposure, and only its eggs rated. + Assert.Equal((100L, 80), (row.RecordedHenDays, row.RatedEggs)); + Assert.Equal(80m, row.HenDayPct); + } + + // The lifecycle rule behind ExpectedFlocks — placed, not yet depleted or + // archived — decides the partial state, the hatched bar, Peak and Avg. It + // had no test: counting every loaded flock instead left all of these green. + [Fact] + public async Task Production_ExpectedFlocks_CountsOnlyFlocksLiveOnThatDay() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var farmId = Guid.NewGuid(); + await factory.SeedEggGradesAsync(accountId, farmId, "Large"); + var early = await factory.SeedFlockAsync(accountId, farmId); + var late = await factory.SeedFlockAsync(accountId, farmId); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + + // `late` is placed on the middle day of the window; `early` is depleted + // on it. So each of the three days expects a different number. + await factory.WithTenantScopeAsync(accountId, async db => + { + var l = await db.Flocks.FirstAsync(x => x.Id == late); + l.Update(l.Name, l.Breed, Today.AddDays(-2), l.InitialCount); + var e = await db.Flocks.FirstAsync(x => x.Id == early); + e.Deplete(Today.AddDays(-2)); + await db.SaveChangesAsync(); + }); + + var report = await client.GetFromJsonAsync( + $"/api/v1/reports/production?from={Today.AddDays(-3):yyyy-MM-dd}&to={Today.AddDays(-1):yyyy-MM-dd}"); + + // Before placement / after depletion, a flock owes nothing. + Assert.Equal(1, report!.Days[0].ExpectedFlocks); // early only + Assert.Equal(2, report.Days[1].ExpectedFlocks); // both, on the boundary day + Assert.Equal(1, report.Days[2].ExpectedFlocks); // late only + } + // Depletion writes no removal movement — the flock's contribution must // terminate at DepletedOn anyway (codex review of #92). [Fact] diff --git a/tools/simulation/ui/specs-screenshots/screenshots.spec.ts b/tools/simulation/ui/specs-screenshots/screenshots.spec.ts index f3c5a5eba..c7aa77394 100644 --- a/tools/simulation/ui/specs-screenshots/screenshots.spec.ts +++ b/tools/simulation/ui/specs-screenshots/screenshots.spec.ts @@ -107,6 +107,28 @@ test.describe("README screenshots", () => { const heights = await bars.evaluateAll((els) => els.map((e) => (e as HTMLElement).style.height)); expect(new Set(heights).size).toBeGreaterThan(1); + // #780 — the day readout's position is derived from the SELECTED slot's own + // element, never from a remembered number, so it cannot end up over a day + // it is not describing. jsdom cannot see this: every offset there is 0, so + // the unit tests are blind to it and only a laid-out page can hold it. + // + // The sequence that broke it: focus one day, hover a far one, take the + // pointer off the strip. The selection returns to the focused day — its + // ring and arrow move back — and a stored centre left the box behind. + const slots = page.locator(".daystrip > .day"); + const readoutLeft = () => page.locator(".tip").evaluate((el) => (el as HTMLElement).style.left); + const slotCount = await slots.count(); + if (slotCount > 3) { + await slots.nth(1).focus(); + const parked = await readoutLeft(); + await slots.nth(slotCount - 1).hover(); + await expect(page.locator(".tip")).toBeVisible(); + await page.mouse.move(2, 2); + // Back on the focused day, and back at that day's own position. + await expect(slots.nth(1)).toHaveClass(/\bon\b/); + expect(await readoutLeft()).toBe(parked); + } + // Stock: at least one segment on the bar. await expect(page.locator(".meter-stack > span").first()).toBeVisible(); diff --git a/web/src/api/cluckwork.ts b/web/src/api/cluckwork.ts index 7683572f3..f3299f350 100644 --- a/web/src/api/cluckwork.ts +++ b/web/src/api/cluckwork.ts @@ -1082,7 +1082,39 @@ export interface ProductionDay { */ fromCounts: number; deaths: number; + /** + * #780 — houses that filed an OFFICIAL entry (Submitted, Locked or + * ManagerAdjusted). The field that separates a day nobody recorded from one + * that genuinely produced no eggs: every other figure here is 0 for both. A + * day holding only a Draft counts 0 here while the capture tiles still show + * it as captured. + */ + recordedFlocks: number; + /** + * #780 — houses that owed a filing that day: placed, not yet depleted or + * archived. `recordedFlocks < expectedFlocks` is a PARTIALLY recorded day, + * whose `totalEggs` is a floor rather than a figure. + */ + expectedFlocks: number; + /** + * #780 — expected flocks with no filing, and the only sound test for a + * partly recorded day. Comparing `recordedFlocks` against `expectedFlocks` + * is not: they count different sets, so expected {A, B} against filings + * {A, C} gives 2 and 2 and hides B. + */ + missingFlocks: number; + /** Every bird alive that day, whether or not its house reported. */ henDays: number; + /** The exposure that reported — `henDayPct`'s denominator (#780). */ + recordedHenDays: number; + /** + * `henDayPct`'s NUMERATOR (#780): the eggs of exactly the flocks whose birds + * are in `recordedHenDays`. Equal to `totalEggs` on any ordinary day; they + * part only where a flock filed while the bird ledger says it had no birds, + * and carrying it is what keeps the rate reproducible from the payload. + */ + ratedEggs: number; + /** `ratedEggs` over `recordedHenDays`. null when nothing reported. */ henDayPct: number | null; } @@ -1093,6 +1125,9 @@ export interface ProductionReport { totalFromCounts: number; totalDeaths: number; totalHenDays: number; + /** `periodHenDayPct`'s denominator and numerator, so it is reproducible (#780). */ + totalRecordedHenDays: number; + totalRatedEggs: number; periodHenDayPct: number | null; gradeTotals: { eggGradeId: string; name: string; quantity: number }[]; } diff --git a/web/src/components/DayStrip.test.tsx b/web/src/components/DayStrip.test.tsx index d31db8160..1b1977b33 100644 --- a/web/src/components/DayStrip.test.tsx +++ b/web/src/components/DayStrip.test.tsx @@ -1,55 +1,216 @@ // 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, expectedFlocks: 3, weekBreak }); +const part = (date: string, eggs: number, heightPct: number): DayStripSlot => + ({ kind: "partial", date, eggs, heightPct, filedFlocks: 1, expectedFlocks: 3, weekBreak: false }); +// Four days, one per state: one that laid, one that laid nothing and was +// recorded, one only some houses reported, one nobody recorded. The last three +// all used to render as the same empty slot. 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), part("2026-07-04", 6, 60), gap("2026-07-03", true)], + max: 10, average: 5, averagePct: 50, complete: 2, partial: 1, 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. - expect(container.querySelectorAll(".daystrip > .day")).toHaveLength(3); +const tip = (s: DayStripSlot) => { + switch (s.kind) { + case "none": return `${s.date} – no flocks`; + case "unrecorded": return `${s.date} – no entry`; + case "partial": return `${s.date} – ${s.eggs} eggs, 1 of 3 flocks`; + case "recorded": return `${s.date} – ${s.eggs} eggs`; + } +}; + +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(4); 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 and the partial day draws its floor; + // the unrecorded day has no bar at all. + expect(bars).toEqual(["100%", "2%", "60%"]); + }); + + 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]); + expect(marked).toEqual([false, 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")], max: null, average: null, averagePct: null, complete: 0, partial: 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(); + expect(screen.getByRole("button", { name: "2026-07-04 – 6 eggs, 1 of 3 flocks" })).toBeInTheDocument(); + }); + + // A partly recorded day is marked in the markup, not only in its name — the + // bar is a floor and a solid bar would assert a drop nobody recorded. + it("marks each slot with its own state so the bar can say it is a floor", () => { + const { container } = render(strip()); + expect(Array.from(container.querySelectorAll(".daystrip > .day")).map((d) => d.className)).toEqual([ + "day day-recorded", "day day-recorded", "day day-partial", "day day-unrecorded day-week", + ]); + }); + + 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[3]); + 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, 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, false]); + }); + + // One tab stop for the whole strip, then the arrow keys along it. Fourteen + // consecutive tab stops past a dashboard panel is a keyboard obstacle, not + // keyboard support. + it("takes one tab stop and moves along the days with the arrow keys", async () => { + const user = userEvent.setup(); + const { container } = render(strip()); + const days = () => Array.from(container.querySelectorAll(".daystrip > .day")); + expect(days().map((d) => d.getAttribute("tabindex"))).toEqual(["0", "-1", "-1", "-1"]); + + await user.tab(); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-01 – 10 eggs"); + await user.keyboard("{ArrowRight}"); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-02 – 0 eggs"); + // The stop follows the selection, so Tab re-enters where the reader left. + expect(days().map((d) => d.getAttribute("tabindex"))).toEqual(["-1", "0", "-1", "-1"]); + await user.keyboard("{End}"); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-03 – no entry"); + // Clamped, never wrapped off the end of the array. + await user.keyboard("{ArrowRight}"); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-03 – no entry"); + await user.keyboard("{Home}"); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-01 – 10 eggs"); + + // Tab leaves the strip in one press rather than walking every day. + await user.tab(); + expect(days().some((d) => d === document.activeElement)).toBe(false); + }); + + // A keyboard user focuses a day, then the pointer wanders across the strip and + // off it. Clearing on mouse-leave took the readout and the ring away from a + // day that still held focus, with no blur to explain it. + it("keeps the focused day's readout when the pointer leaves the strip", async () => { + const user = userEvent.setup(); + const { container } = render(strip()); + const days = () => Array.from(container.querySelectorAll(".daystrip > .day")); + + await user.tab(); + await user.keyboard("{ArrowRight}"); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-02 – 0 eggs"); + + await user.hover(days()[3]); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-03 – no entry"); + + await user.unhover(days()[3]); + // Back to where the keyboard is, not gone. + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-02 – 0 eggs"); + expect(days().map((d) => d.classList.contains("on"))).toEqual([false, true, false, false]); + + // With nothing focused, leaving the strip still closes it. + await user.tab(); + await user.hover(days()[0]); + await user.unhover(days()[0]); + expect(container.querySelector(".tip")).toBeNull(); + }); + + // The selection is a roving one, not a toggle: `aria-pressed` said "this + // button is pressed in" about a chart slot. Nothing read the attribute, so + // hardcoding it to false left the whole suite green. + it("marks the selected slot as current, and only that slot", async () => { + const user = userEvent.setup(); + const { container } = render(strip()); + const days = () => Array.from(container.querySelectorAll(".daystrip > .day")); + expect(days().map((d) => d.getAttribute("aria-current"))).toEqual(["false", "false", "false", "false"]); + await user.hover(days()[2]); + expect(days().map((d) => d.getAttribute("aria-current"))).toEqual(["false", "false", "true", "false"]); + expect(container.querySelector("[aria-pressed]")).toBeNull(); + }); + + // The readout is not a live region and must not be: the selected slot's own + // accessible name is this exact sentence, so a live region announced it twice. + it("hides the readout from assistive tech, leaving the slot's name to say it", () => { + const { container } = render(strip()); + expect(container.querySelector(".tipdock")).toHaveAttribute("aria-hidden", "true"); + expect(container.querySelector("[role=status]")).toBeNull(); + }); + + // The selection is held by DATE. Holding a snapshot of the slot left the + // readout showing a figure the same day's accessible name had already replaced. + it("follows a data change under an open readout instead of showing a stale figure", async () => { + const user = userEvent.setup(); + const { container, rerender } = render(strip()); + await user.hover(container.querySelectorAll(".daystrip > .day")[0]); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-01 – 10 eggs"); + + rerender(strip({ ...data, slots: [rec("2026-07-01", 999, 100), ...data.slots.slice(1)] })); + expect(container.querySelector(".tip")!.textContent).toBe("2026-07-01 – 999 eggs"); + expect(screen.getByRole("button", { name: "2026-07-01 – 999 eggs" })).toBeInTheDocument(); + }); + + // A day that leaves the window takes its readout with it, rather than leaving + // a box pointing at a slot that is no longer drawn. + it("closes the readout when the selected day drops out of the window", async () => { + const user = userEvent.setup(); + const { container, rerender } = render(strip()); + await user.hover(container.querySelectorAll(".daystrip > .day")[0]); + expect(container.querySelector(".tip")).not.toBeNull(); + rerender(strip({ ...data, slots: data.slots.slice(1) })); + expect(container.querySelector(".tip")).toBeNull(); + }); + + // 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..87873a3d0 100644 --- a/web/src/components/DayStrip.tsx +++ b/web/src/components/DayStrip.tsx @@ -1,40 +1,155 @@ // 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, a day nobody +// recorded draws nothing, and a day only some houses reported is marked as a +// floor rather than a figure. // -// `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 }: { +// One tab stop, not fourteen. The slots are peers, so they behave like a +// toolbar: Tab enters the strip at the selected day (the first, initially) and +// the arrow keys move along it. Fourteen consecutive tab stops on the way past +// a dashboard panel is not keyboard support, it is a keyboard obstacle. +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; }) { + // The selected day is held by DATE, never as a snapshot of the slot. Holding + // the object meant a refetch left the readout showing a figure the same day's + // own accessible name had already replaced, and an index could point outside + // a shortened array. + const [activeDate, setActiveDate] = useState(null); + // Where the keyboard is, which is NOT the same as what is selected: a pointer + // crossing the strip and leaving clears the selection without moving DOM + // focus, and deriving the tab stop from the selection then snapped it back to + // day 1 while focus sat on day 3 — the next ArrowRight moved backwards. + const [focusDate, setFocusDate] = useState(null); + const dockRef = useRef(null); + const tipRef = useRef(null); + const stripRef = useRef(null); + + const activeIndex = data.slots.findIndex((s) => s.date === activeDate); + const active = activeIndex === -1 ? null : data.slots[activeIndex]; + // Tab lands where the keyboard last was, or the first day — never on nothing, + // which is what an all-`-1` strip would give. + const focusIndex = data.slots.findIndex((s) => s.date === focusDate); + const stopIndex = focusIndex === -1 ? 0 : focusIndex; + + // 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. `tip(active)` is in the deps because the farm + // locale resolves after mount, which changes the text's width without + // changing which day is selected. 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; + // Measured from the SELECTED slot's own element, never from a remembered + // number. A stored centre went stale: focus an early day, hover a later + // one, move the pointer off the strip, and the restore put the box over the + // hovered day while the arrow and ring stayed on the focused one. The arrow + // is anchored to this same element, so deriving the box from it is the only + // way the two cannot disagree. + const slot = stripRef.current?.children[activeIndex]; + if (dock === null || box === null || !(slot instanceof HTMLElement)) return; + const half = box.offsetWidth / 2; + const centre = slot.offsetLeft + slot.offsetWidth / 2; + const x = half * 2 >= dock.offsetWidth + ? dock.offsetWidth / 2 + : Math.min(Math.max(centre, half), dock.offsetWidth - half); + box.style.left = `${x}px`; + }, [activeIndex, tip]); + + const select = (slot: DayStripSlot, keyboard = false) => { + setActiveDate(slot.date); + if (keyboard) setFocusDate(slot.date); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 + : e.key === "Home" ? -data.slots.length : e.key === "End" ? data.slots.length : 0; + if (step === 0) return; + e.preventDefault(); + const next = Math.min(data.slots.length - 1, Math.max(0, stopIndex + step)); + const el = stripRef.current?.children[next]; + if (el instanceof HTMLElement) el.focus(); + }; + return (
{title} - {peak} + + {average !== null && {average}} + {peak} +
-
- {data.slots.map((s) => ( - - {s.recorded && } - + {/* A reserved row, 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. + + aria-hidden, because the selected slot's own accessible name is this + exact sentence. A live region here announced it a second time, and a + live region mounted together with its text is unreliably announced + anyway (NamedEntityPicker keeps a permanently mounted one for that + reason). */} + +
setActiveDate( + stripRef.current?.contains(document.activeElement) === true ? focusDate : null, + )} + onKeyDown={onKeyDown} + > + {data.slots.map((s, i) => ( + ))} + {/* Last, so it reads ACROSS the bars rather than behind them — a + reference the eye cannot follow over the tall days is not one. */} + {data.averagePct !== null && ( +
{from} @@ -43,3 +158,21 @@ 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 "none": + case "unrecorded": + return null; + case "partial": + 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..452156156 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -778,15 +778,53 @@ export const en = { // Last 14 days (#654, #777): the production report's own figures — // submitted days only, fixed window (yesterday back). The strip plots eggs // 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. + // for the last 7 finished days, and its delta against the 7 before. + // ("Finished", not "complete": since #780 a COMPLETE day means one every + // flock recorded, which is a different property and decides Peak and Avg.) 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). + // Peak and average are over the days EVERY flock recorded — a partly + // recorded day's total is a floor, so averaging it in would lower the + // reference by however many flocks forgot. One count for the days that are + // not complete, whichever way they fall short: the slot's own name says + // which, and i18next selects a plural on one number, not two. + trendStripLabel: "Eggs per day, last 14 days. Peak {{max}}, average {{avg}}. Every flock recorded every day.", + trendStripLabelBlanks_one: "Eggs per day, last 14 days. Peak {{max}}, average {{avg}} across the days every flock recorded. {{blank}} day is not fully recorded.", + trendStripLabelBlanks_other: "Eggs per day, last 14 days. Peak {{max}}, average {{avg}} across the days every flock recorded. {{blank}} days are not fully recorded.", + // Nothing at all in the window, 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.", + // Some days have figures, but none is complete, so there is still nothing + // to take a peak or an average from. + trendStripLabelNoComplete: "Eggs per day, last 14 days. No day was recorded by every flock, so there is no peak or average to give.", + // Nobody owed a filing in this window — no flock was placed yet, or the + // last one has gone. Distinct from nobody having filed. + trendStripLabelNoFlocks: "Eggs per day, last 14 days. No flock was on the farm in this window.", 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", + // Only some flocks filed, so the total is a floor and the sentence says so + // rather than presenting it as the day's output. + // Kept short on purpose: at 331px the longer draft ("… eggs so far, 1 of 2 + // flocks recorded") was clipped mid-word on the running panel, and this is + // the readout carrying the most information. "N of M flocks" already says + // the total is a floor. + trendDayTipPartial_one: "{{date}} – {{total}} egg, {{recorded}} of {{expected}} flocks", + trendDayTipPartial_other: "{{date}} – {{total}} eggs, {{recorded}} of {{expected}} flocks", + // 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", + // The farm had no flocks that day, so nothing was owed and nothing is + // missing. Counting these as gaps made a new farm's first fortnight + // announce fourteen missing filings. + trendDayTipNoFlocks: "{{date}} – no flocks", henDaySubLabel: "Hen-day, last 7 days against the 7 before", henDayDeltaUp: "+{{delta}} pts", henDayDeltaDown: "−{{delta}} pts", @@ -2129,6 +2167,9 @@ export const en = { conditionHeader: "Condition", deathsHeader: "Deaths", henDaysHeader: "Hen-days", + // #780 — the hen-day %'s denominator: the exposure that reported. + recordedHenDaysHeader: "Recorded", + ratedEggsHeader: "Rated eggs", henDayPctHeader: "Hen-day %", periodRowLabel: "Period", gradeTotalsLabel: "By grade:", @@ -2607,9 +2648,16 @@ 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 finished days and how it moved against the 7 before. Point at a day, " + + "or tab into the chart and use the arrow keys, 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; a day only some flocks submitted is drawn hatched, " + + "because its total is the least the farm produced rather than what it produced. " + + "Peak and Avg come only from the days every flock recorded. " + + "Hen-day % divides by the hen-days of the flocks that recorded, so a flock that forgets lowers " + + "what the figure is measured over, never the rate itself. 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 " @@ -3000,8 +3048,13 @@ export const en = { reportsHeading: "Reports", reportsProduction: "Production (everyone): pick a date range — per-day eggs, losses, sellable, " - + "condition, deaths, and hen-day % (eggs collected ÷ birds alive that day × 100), " - + "with period totals and a by-grade breakdown. Draft and voided entries don't count.", + + "condition, deaths, and hen-day %, with period totals and a by-grade breakdown. " + + "Draft and voided entries don't count. Hen-day % is eggs collected ÷ hen-days × 100, and it " + + "divides by the hen-days of the flocks that recorded that day — a flock that " + + "filed nothing produced no eggs this report can see, so counting its birds would report a lay " + + "rate nothing supports. The Hen-days column is every bird alive, recorded or " + + "not, so the gap between the two is what is missing. A day nobody recorded shows — rather than " + + "0%.", // #396 — the new column needs explaining because two of its neighbours look // like they should already cover it: Losses shows the same cracked/dirty // numbers (as counts, whether sold or not), and Sellable is the graded diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index e802da320..090e7516c 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -595,11 +595,21 @@ 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 lotes registraron todos los días.", + trendStripLabelBlanks_one: "Huevos por día, últimos 14 días. Máximo {{max}}, promedio {{avg}} entre los días que registraron todos los lotes. {{blank}} día no está completo.", + trendStripLabelBlanks_other: "Huevos por día, últimos 14 días. Máximo {{max}}, promedio {{avg}} entre los días que registraron todos los lotes. {{blank}} días no están completos.", + trendStripLabelNone: "Huevos por día, últimos 14 días. Ningún día de este período tiene registro.", + trendStripLabelNoComplete: "Huevos por día, últimos 14 días. Ningún día fue registrado por todos los lotes, así que no hay máximo ni promedio.", + trendStripLabelNoFlocks: "Huevos por día, últimos 14 días. No hubo ningún lote en la granja en este período.", trendScaleTitle: "Huevos por día", trendPeak: "Máximo {{total}}", + trendAvg: "Prom. {{total}}", + trendDayTip_one: "{{date}} – {{total}} huevo", + trendDayTip_other: "{{date}} – {{total}} huevos", + trendDayTipPartial_one: "{{date}} – {{total}} huevo, {{recorded}} de {{expected}} lotes", + trendDayTipPartial_other: "{{date}} – {{total}} huevos, {{recorded}} de {{expected}} lotes", + trendDayTipNone: "{{date}} – sin registro", + trendDayTipNoFlocks: "{{date}} – sin lotes", henDaySubLabel: "Postura, últimos 7 días frente a los 7 anteriores", henDayDeltaUp: "+{{delta}} pts", henDayDeltaDown: "−{{delta}} pts", @@ -1599,6 +1609,8 @@ export const es = { conditionHeader: "Con defecto", deathsHeader: "Muertes", henDaysHeader: "Días-gallina", + recordedHenDaysHeader: "Registrados", + ratedEggsHeader: "Huevos contados", henDayPctHeader: "% días-gallina", periodRowLabel: "Período", gradeTotalsLabel: "Por grado:", @@ -1997,10 +2009,16 @@ 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 entre al gráfico con el tabulador y use las flechas, 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; un día que solo enviaron algunos lotes se " + + "dibuja rayado, porque su total es lo mínimo que produjo la granja y no lo que produjo. " + + "Máximo y Prom. salen solo de los días que registraron todos los " + + "lotes. El % de postura divide entre los días-gallina de los lotes que registraron, así que un lote que olvida " + + "reduce sobre cuánto se mide la cifra, nunca la cifra misma. 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 " @@ -2410,9 +2428,13 @@ export const es = { reportsHeading: "Informes", reportsProduction: "Producción (todos): elija un rango de fechas — huevos por día, pérdidas, " - + "vendibles, con defecto, muertes, y % de puesta diaria (huevos recolectados ÷ " - + "aves vivas ese día × 100), con totales del período y un desglose por grado. Las entradas en " - + "borrador y anuladas no cuentan.", + + "vendibles, con defecto, muertes, y % de puesta diaria, con totales del período " + + "y un desglose por grado. Las entradas en borrador y anuladas no cuentan. El % de puesta diaria " + + "es huevos recolectados ÷ días-gallina × 100, y divide entre los días-gallina de los " + + "lotes que registraron ese día — un lote que no envió nada no produjo huevos " + + "que este informe pueda ver, así que contar sus aves informaría una tasa que nada respalda. La " + + "columna Días-gallina son todas las aves vivas, registradas o no, así que la " + + "diferencia entre ambas es lo que falta. Un día que nadie registró muestra — en vez de 0 %.", // #396 — machine-drafted, pending native review (#182). reportsCondition: "Con defecto: huevos rajados y sucios que pasaron a inventario en vez de " diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 13f893d89..9abb59816 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -620,11 +620,26 @@ 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.", + // "average", not "katamtaman": the figure beside the chart is labelled + // Average in this locale, and #688 says a locale must not use two words for + // one control. (`katamtaman` also reads as middling rather than a mean.) + trendStripLabel: "Itlog bawat araw, huling 14 araw. Pinakamataas {{max}}, average {{avg}}. Lahat ng kawan ay may tala bawat araw.", + trendStripLabelBlanks_one: "Itlog bawat araw, huling 14 araw. Pinakamataas {{max}}, average {{avg}} sa mga araw na may tala ang lahat ng kawan. {{blank}} araw ang hindi kumpleto.", + trendStripLabelBlanks_other: "Itlog bawat araw, huling 14 araw. Pinakamataas {{max}}, average {{avg}} sa mga araw na may tala ang lahat ng kawan. {{blank}} araw ang hindi kumpleto.", + trendStripLabelNone: "Itlog bawat araw, huling 14 araw. Walang araw sa panahong ito ang may tala.", + trendStripLabelNoComplete: "Itlog bawat araw, huling 14 araw. Walang araw na may tala ang lahat ng kawan, kaya walang pinakamataas o average.", + trendStripLabelNoFlocks: "Itlog bawat araw, huling 14 araw. Walang kawan sa bukid sa panahong ito.", trendScaleTitle: "Itlog bawat araw", trendPeak: "Pinakamataas {{total}}", + // The loanword, as this catalog already uses for Hen-day and Draft: + // "Katamtaman" wrapped the caption onto a second line on a 331px panel. + trendAvg: "Average {{total}}", + trendDayTip_one: "{{date}} – {{total}} itlog", + trendDayTip_other: "{{date}} – {{total}} itlog", + trendDayTipPartial_one: "{{date}} – {{total}} itlog, {{recorded}} sa {{expected}} kawan", + trendDayTipPartial_other: "{{date}} – {{total}} itlog, {{recorded}} sa {{expected}} kawan", + trendDayTipNone: "{{date}} – walang tala", + trendDayTipNoFlocks: "{{date}} – walang kawan", henDaySubLabel: "Hen-day, huling 7 araw laban sa naunang 7", henDayDeltaUp: "+{{delta}} pts", henDayDeltaDown: "−{{delta}} pts", @@ -1656,6 +1671,8 @@ export const tl = { conditionHeader: "May depekto", deathsHeader: "Namatay", henDaysHeader: "Hen-days", + recordedHenDaysHeader: "May tala", + ratedEggsHeader: "Itlog na binilang", henDayPctHeader: "Hen-day %", periodRowLabel: "Panahon", gradeTotalsLabel: "Ayon sa grado:", @@ -2075,8 +2092,15 @@ 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 " + + "sa 7 araw bago nito. Ituro ang isang araw, o pumasok sa tsart gamit ang tab at gamitin ang mga " + + "arrow key, 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 " + + "araw na iilang kawan lang ang nagsumite ay may guhit, dahil ang kabuuan nito ay ang " + + "pinakamababang naprodyus at hindi ang aktwal na naprodyus. Ang Pinakamataas at " + + "Average ay mula lamang sa mga araw na may tala ang lahat ng kawan. Ang hen-day % " + + "ay hinahati sa hen-days ng mga kawang may tala, kaya ang kawang nakalimot ay nagpapaliit sa sinusukat, " + + "hindi sa mismong porsyento. 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 " @@ -2504,9 +2528,14 @@ export const tl = { reportsHeading: "Mga Report", reportsProduction: "Production (lahat): pumili ng date range — itlog kada araw, nawala, naibibenta, " - + "may depekto, namatay, at hen-day % (itlog na nakolekta ÷ ibong buhay sa araw na " - + "iyon × 100), may kasamang period total at breakdown kada grado. Hindi binibilang ang draft at " - + "na-void na entry.", + + "may depekto, namatay, at hen-day %, may kasamang period total at breakdown kada " + + "grado. Hindi binibilang ang draft at na-void na entry. Ang hen-day % ay itlog na nakolekta ÷ " + + "hen-days × 100, at hinahati ito sa hen-days ng mga kawang may tala sa araw na " + + "iyon — ang kawang walang isinumite ay walang naprodyus na itlog na nakikita ng report " + + "na ito, kaya ang pagbilang sa mga ibon nito ay mag-uulat ng rate na walang basehan. Ang column " + + "na Hen-days ay lahat ng ibong buhay, may tala man o wala, kaya ang agwat sa " + + "pagitan ng dalawa ay ang nawawala. Ang araw na walang nagtala ay nagpapakita ng — sa halip na " + + "0%.", // #396 — machine-drafted, pending native review (#182). reportsCondition: "May depekto: basag at maruming itlog na naging stock sa halip na ituring na " diff --git a/web/src/lib/dashboard.test.ts b/web/src/lib/dashboard.test.ts index a75f75213..ce443cb8a 100644 --- a/web/src/lib/dashboard.test.ts +++ b/web/src/lib/dashboard.test.ts @@ -18,13 +18,24 @@ 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 => ({ +// One house, and it recorded: the ordinary complete day. `missing` is the day +// nobody recorded and `partly` the day some houses did — the two distinctions +// #780 added, and the two that used to arrive identical to a complete zero. +const day = (date: string, totalEggs: number, henDays = 100, recordedFlocks = 1, expectedFlocks = 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, recordedFlocks, expectedFlocks, + missingFlocks: Math.max(0, expectedFlocks - recordedFlocks), + henDays, + recordedHenDays: expectedFlocks > 0 ? Math.round((henDays * recordedFlocks) / expectedFlocks) : 0, + ratedEggs: recordedFlocks > 0 ? totalEggs : 0, + henDayPct: henDays > 0 && recordedFlocks > 0 ? Math.round((totalEggs * 1000) / henDays) / 10 : null, }); +const missing = (date: string) => day(date, 0, 100, 0, 1); +const partly = (date: string, totalEggs: number, recorded = 1, expected = 3) => + day(date, totalEggs, 100, recorded, expected); const report = (periodHenDayPct: number | null, days: ProductionDay[] = []): ProductionReport => ({ days, totalEggs: 0, totalSellable: 0, totalFromCounts: 0, totalDeaths: 0, totalHenDays: 0, - periodHenDayPct, gradeTotals: [], + totalRecordedHenDays: 0, totalRatedEggs: 0, periodHenDayPct, gradeTotals: [], }); describe("captureTiles (#654, INV-3, INV-9)", () => { @@ -93,52 +104,128 @@ 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({ max: 10, average: 7.7, complete: 3, partial: 0, 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({ complete: 2, unrecorded: 1 }); + }); + + it("computes the peak and the average over the recorded days only", () => { + // The unrecorded day is worth nothing to either. Averaging over the + // calendar rather than over the evidence would report 166.7. + const d = dayStrip({ days: [day("2026-07-01", 300), missing("2026-07-02"), day("2026-07-03", 200)] }); + expect(d).toMatchObject({ max: 300, average: 250, averagePct: 83.3 }); + }); + + // A day only some houses reported is its own state: its total is a floor, so + // it must not set the peak, must not move the average, and must not be drawn + // as a complete day that produced less. + it("keeps a partly recorded day out of the peak and the average", () => { + const d = dayStrip({ days: [day("2026-07-01", 300), partly("2026-07-02", 500, 2, 3), day("2026-07-03", 200)] }); + expect(d).toMatchObject({ max: 300, average: 250, complete: 2, partial: 1, unrecorded: 0 }); + expect(d.slots[1]).toMatchObject({ kind: "partial", eggs: 500, filedFlocks: 2, expectedFlocks: 3 }); + // 500 over a 300 peak would overflow the slot, so the bar caps at the top. + expect(d.slots[1]).toMatchObject({ heightPct: 100 }); }); - 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); + // Codex review: completeness compared two COUNTS over different sets. A flock + // filing outside its lifecycle window counts in `recordedFlocks` but answers + // no expectation, so expected {A, B} against filings {A, C} gave 2 and 2 — + // and B's missing filing read as a complete day whose short total then set + // the Peak and moved the Avg. + it("does not call a day complete when the counts match but a flock is missing", () => { + const skewed: ProductionDay = { + ...day("2026-07-02", 400), recordedFlocks: 2, expectedFlocks: 2, missingFlocks: 1, + }; + const d = dayStrip({ days: [day("2026-07-01", 900), skewed] }); + expect(d.slots[1]).toMatchObject({ kind: "partial", filedFlocks: 1, expectedFlocks: 2 }); + // The short day sets neither figure. + expect(d).toMatchObject({ max: 900, average: 900, complete: 1, partial: 1 }); + }); + + it("has no peak or average when no day was recorded by every flock", () => { + const d = dayStrip({ days: [partly("2026-07-01", 500, 2, 3), missing("2026-07-02")] }); + expect(d).toMatchObject({ max: null, average: null, averagePct: null, complete: 0, partial: 1, unrecorded: 1 }); + }); + + it("owes nothing on a day the farm had no flocks, rather than counting a gap", () => { + // Before the first placement, 0 recorded of 0 expected is not a shortfall. + // The old shape called it `unrecorded`, so a new farm's first fortnight + // announced fourteen missing filings against an obligation nobody had. + const d = dayStrip({ days: [day("2026-07-01", 0, 0, 0, 0)] }); + expect(d).toMatchObject({ complete: 0, partial: 0, unrecorded: 0 }); + expect(d.slots[0].kind).toBe("none"); + }); + + // `isComplete` is `recorded > 0 && recorded >= expected`. The `>=` decides + // only the case where MORE flocks filed than were expected, which happens at + // a depletion boundary — a flock files against a date the bird ledger says it + // had already ended. Under `===` that day flips to partial and its readout + // reads "2 of 1 flocks". The `> 0` conjunct short-circuits the 0/0 case above + // before `>=` is ever evaluated, so that test does NOT cover this. + it("counts a day where more flocks filed than were expected as complete", () => { + const d = dayStrip({ days: [day("2026-07-01", 90, 100, 2, 1)] }); + expect(d).toMatchObject({ complete: 1, partial: 0, unrecorded: 0 }); + expect(d.slots[0].kind).toBe("recorded"); }); 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({ 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({ max: null, average: null, complete: 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 }); + 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..0184ad89b 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, or only some of them. // // 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,110 @@ 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 } +// THREE states, as a discriminated union rather than flags beside a height. The +// two-state version of this shipped in the first draft and was wrong in the same +// direction the issue is about: a day where one house of three filed is not a +// low day, it is a day whose total is a FLOOR, and drawing it like a complete +// day asserts a drop in production that the farm's own records do not claim. +export type DayStripSlot = + // `none` is a day that owed no filing at all — before the first placement, or + // after the last flock left. It is NOT a gap, and counting it as one made a + // farm's first fortnight announce fourteen missing days. + | { kind: "none"; date: string; weekBreak: boolean } + | { kind: "unrecorded"; date: string; expectedFlocks: number; weekBreak: boolean } + // `filedFlocks` is how many of the flocks that OWED a count filed one, which + // is what the readout compares against `expectedFlocks`. It is not + // `recordedFlocks`: a flock filing outside its lifecycle window is counted + // there and answers for nobody's expectation. + | { kind: "partial"; date: string; eggs: number; heightPct: number; filedFlocks: number; expectedFlocks: number; weekBreak: boolean } + | { kind: "recorded"; date: string; eggs: number; heightPct: number; weekBreak: boolean }; + +export interface DayStripData { + slots: DayStripSlot[]; + // Over the COMPLETE days only — the days every house reported. A partial + // day's total is a floor, so averaging it in drags the reference line down by + // however many houses forgot, which is the same defect one layer up. + 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; + complete: number; + partial: 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 day with a figure 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". A day nobody recorded +// 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: [], max: null, average: null, averagePct: null, + complete: 0, partial: 0, unrecorded: 0, + }; + if (days.length === 0) return empty; + + // Completeness is `missingFlocks`, never a comparison of the two counts. + // `recordedFlocks` counts any flock that filed and `expectedFlocks` counts the + // flocks that owed a count, and those are different SETS: expected {A, B} + // against filings {A, C} gives 2 and 2, so B's missing filing read as a + // complete day and its shortfall went into Peak and Avg. The server compares + // identities and reports the shortfall directly. + const isComplete = (d: ProductionDay) => d.recordedFlocks > 0 && d.missingFlocks === 0; + const complete = days.filter(isComplete).map((d) => d.totalEggs); + const max = complete.length === 0 ? null : Math.max(...complete); + const average = complete.length === 0 + ? null + : r1(complete.reduce((a, v) => a + v, 0) / complete.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] }; + + // Height is a share of the complete-day peak. A partial day can therefore + // exceed 100% — two big houses out of three can beat a quiet complete day — + // so it is capped rather than allowed to overflow its slot. + const height = (eggs: number) => + max !== null && max > 0 ? Math.min(100, Math.max(2, r1((eggs / max) * 100))) : 2; + + const slots: DayStripSlot[] = days.map((d, i) => { + const weekBreak = i === breakAt; + if (d.expectedFlocks === 0 && d.recordedFlocks === 0) { + return { kind: "none", date: d.date, weekBreak }; + } + if (d.recordedFlocks === 0) { + return { kind: "unrecorded", date: d.date, expectedFlocks: d.expectedFlocks, weekBreak }; + } + if (!isComplete(d)) { + return { + kind: "partial", date: d.date, eggs: d.totalEggs, heightPct: height(d.totalEggs), + filedFlocks: d.expectedFlocks - d.missingFlocks, expectedFlocks: d.expectedFlocks, weekBreak, + }; + } + return { kind: "recorded", date: d.date, eggs: d.totalEggs, heightPct: height(d.totalEggs), weekBreak }; + }); + + return { + slots, + max, + average, + // A window whose every complete 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), + complete: complete.length, + partial: slots.filter((s) => s.kind === "partial").length, + unrecorded: slots.filter((s) => s.kind === "unrecorded").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..af51e9480 100644 --- a/web/src/routes/Dashboard.test.tsx +++ b/web/src/routes/Dashboard.test.tsx @@ -42,12 +42,20 @@ 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 => ({ +// One house that recorded — the ordinary complete day. `recordedFlocks: 0` is +// the day nobody recorded, and `recordedFlocks < expectedFlocks` the day only +// some houses did; both used to arrive indistinguishable from a real zero. +const day = (date: string, totalEggs: number, recordedFlocks = 1, expectedFlocks = 1): ProductionDay => ({ date, totalEggs, cracked: 0, dirty: 0, discarded: 0, sellable: totalEggs, fromCounts: 0, - deaths: 0, henDays: 100, henDayPct: totalEggs, + deaths: 0, recordedFlocks, expectedFlocks, + missingFlocks: Math.max(0, expectedFlocks - recordedFlocks), + henDays: 100, + recordedHenDays: expectedFlocks > 0 ? Math.round((100 * recordedFlocks) / expectedFlocks) : 0, + ratedEggs: recordedFlocks > 0 ? totalEggs : 0, + henDayPct: recordedFlocks > 0 ? totalEggs : null, }); const report = (periodHenDayPct: number | null, days: ProductionDay[]): ProductionReport => ({ - days, totalEggs: 0, totalSellable: 0, totalFromCounts: 0, totalDeaths: 0, totalHenDays: 0, + days, totalEggs: 0, totalSellable: 0, totalFromCounts: 0, totalDeaths: 0, totalHenDays: 0, totalRecordedHenDays: 0, totalRatedEggs: 0, periodHenDayPct, gradeTotals: [], }); const STOCK: StockRow[] = [ @@ -177,7 +185,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 flock recorded every day.", + }); // 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 +206,125 @@ 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, recordedFlocks: 0, missingFlocks: d.expectedFlocks } : d)), + }))); + + it("names the days that are not fully recorded, 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 are not fully recorded\.$/ })).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. + // #780 — the branch that exists so the panel never announces a peak it has no + // evidence for. It shipped unrendered by any test: disabling it left the whole + // suite green while the label said "Peak 0, average 0". + it("announces no peak and no average when nothing in the window was recorded", 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, periodHenDayPct: null, + days: r.days.map((d) => ({ ...d, totalEggs: 0, recordedFlocks: 0, missingFlocks: d.expectedFlocks })), + }))); 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. No day in this window has an entry.", + }); + // No bars at all, and the scale shows a dash rather than a fabricated 0. + expect(strip.querySelectorAll(".day > i")).toHaveLength(0); expect(strip.querySelectorAll(".day")).toHaveLength(14); - expect(strip.querySelectorAll(".day > i")).toHaveLength(8); // 4 per window + expect(screen.getByText("—", { selector: ".trend-peak" })).toBeInTheDocument(); + expect(screen.queryByText(/^Avg/)).not.toBeInTheDocument(); + }); + + // Recorded, but never by every house — so there is still no complete day to + // take a peak or an average from, and saying so is a different sentence. + it("announces no peak when some flocks recorded every day but never all of them", async () => { + mockReport.mockImplementation((from, to) => + reportByWindow(today)(from, to).then((r) => ({ + ...r, days: r.days.map((d) => ({ ...d, recordedFlocks: 1, expectedFlocks: 3, missingFlocks: 2 })), + }))); + renderWithProviders(); + expect(await screen.findByRole("group", { + name: "Eggs per day, last 14 days. No day was recorded by every flock, so there is no peak or average to give.", + })).toBeInTheDocument(); + }); + + // A partly recorded day's total is a floor. It gets its own slot state and + // its own sentence, and it must not drag the average down. + it("marks a partly recorded day and keeps it out of the average", async () => { + mockReport.mockImplementation((from, to) => + reportByWindow(today)(from, to).then((r) => ({ + ...r, days: r.days.map((d, i) => (i === 6 ? { ...d, recordedFlocks: 1, expectedFlocks: 3, missingFlocks: 2 } : d)), + }))); + renderWithProviders(); + const strip = await screen.findByRole("group", { name: /Eggs per day, last 14 days/ }); + expect(strip.querySelectorAll(".day-partial")).toHaveLength(2); // day 6 of each window + const names = screen.getAllByRole("button") + .map((b) => b.getAttribute("aria-label")) + .filter((n): n is string => n !== null && n.includes("of 3 flocks")); + expect(names).toHaveLength(2); + expect(names[0]).toMatch(/eggs, 1 of 3 flocks$/); + }); + + 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, recordedFlocks: i > 5 ? 0 : 1, missingFlocks: i > 5 ? d.expectedFlocks : 0 } : d)), + }))); + renderWithProviders(); + 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); + }); + + // The readout's plural selects on the EGG count, which is what the noun + // beside it is. Selecting on the flock count rendered "1 eggs", and no test + // used a partly recorded day holding exactly one egg, so the bug was + // invisible to the whole suite. + it("says '1 egg' on a partly recorded day that produced one", async () => { + mockReport.mockImplementation((from, to) => + reportByWindow(today)(from, to).then((r) => ({ + ...r, + days: r.days.map((d, i) => (i === 6 ? { ...d, totalEggs: 1, recordedFlocks: 1, expectedFlocks: 3, missingFlocks: 2 } : d)), + }))); + 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("of 3 flocks")); + expect(names).toHaveLength(2); + expect(names[0]).toMatch(/ 1 egg, 1 of 3 flocks$/); + }); + + 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..5de1db64b 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,12 +127,53 @@ 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) => { + // Four states, none of which may report a figure it does not have. max and + // average are null together — both come from the COMPLETE days — so a + // window with none gets a sentence rather than a formatted 0, and which + // sentence depends on whether anything was recorded at all. + if (line.max === null || line.average === null) { + // A window where no flock ever owed a filing is not a window of missing + // ones. The day-level fix for that landed without this, so a new farm's + // strip drew fourteen blank-but-blameless slots and then announced that + // none of them had an entry. + if (line.partial === 0 && line.unrecorded === 0) return t("trendStripLabelNoFlocks"); + return line.partial === 0 ? t("trendStripLabelNone") : t("trendStripLabelNoComplete"); + } + const figures = { max: fmt.count(line.max), avg: fmt.count(line.average, 1) }; + const gaps = line.partial + line.unrecorded; + return gaps === 0 ? t("trendStripLabel", figures) - : t("trendStripLabelBlanks", { ...figures, count: blank, blank: fmt.count(blank) }); + : t("trendStripLabelBlanks", { ...figures, count: gaps, blank: fmt.count(gaps) }); + }; + // One day's readout, and the accessible name of its slot. Every figure is + // farm-locale formatted before it reaches a catalog string (#650). The + // unrecorded arm carries no count at all — that is the point of #780 — and + // the partial arm says its total is a floor rather than the day's output. + const trendTip = (slot: DayStripSlot) => { + const date = fmt.date(slot.date); + switch (slot.kind) { + case "none": + return t("trendDayTipNoFlocks", { date }); + case "unrecorded": + return t("trendDayTipNone", { date }); + case "partial": + // Plural on the EGG count, which is what the noun beside it is. It + // selected on the flock count, so a partly recorded day with one egg + // rendered "1 eggs". The flock noun stays plural unconditionally and is + // safe there: `partial` requires 1 <= recorded < expected, so `expected` + // is never below 2. + return t("trendDayTipPartial", { + date, count: slot.eggs, total: fmt.count(slot.eggs), + recorded: fmt.count(slot.filedFlocks), expected: fmt.count(slot.expectedFlocks), + }); + case "recorded": + return t("trendDayTip", { date, count: slot.eggs, total: fmt.count(slot.eggs) }); + default: { + const _exhaustive: never = slot; + return _exhaustive; + } + } }; const deltaClass = (delta: number | null) => delta === null || delta === 0 ? "trend-delta" : delta < 0 ? "trend-delta is-down" : "trend-delta is-up"; @@ -191,7 +236,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..c58d0ea0a 100644 --- a/web/src/routes/ReportsPage.test.tsx +++ b/web/src/routes/ReportsPage.test.tsx @@ -38,10 +38,10 @@ 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, recordedFlocks: 1, expectedFlocks: 1, missingFlocks: 0, henDays: 98, recordedHenDays: 98, ratedEggs: 100, henDayPct: 91.8 }, + { date: "2026-07-18", totalEggs: 95, cracked: 1, dirty: 1, discarded: 2, sellable: 91, fromCounts: 0, deaths: 0, recordedFlocks: 1, expectedFlocks: 1, missingFlocks: 0, henDays: 98, recordedHenDays: 98, ratedEggs: 95, henDayPct: null }, ], - totalEggs: 195, totalSellable: 181, totalFromCounts: 6, totalDeaths: 1, totalHenDays: 196, periodHenDayPct: 92.3, + totalEggs: 195, totalSellable: 181, totalFromCounts: 6, totalDeaths: 1, totalHenDays: 196, totalRecordedHenDays: 196, totalRatedEggs: 195, periodHenDayPct: 92.3, gradeTotals: [ { eggGradeId: "gr1", name: "Grade A", quantity: 60 }, { eggGradeId: "gr2", name: "Grade B", quantity: 30 }, @@ -88,15 +88,21 @@ describe("ReportsPage production section (renders for every role)", () => { renderWithProviders(, { token: NON_ADMIN }); const row1 = await screen.findByRole("row", { name: /07\/19\/2026/ }); - within(row1).getByText("100"); // totalEggs + // #780 — Eggs and Rated eggs are equal on an ordinary day, which is the + // point: a gap between them means a filing and the bird ledger disagree + // about that date, and the rate divides the SECOND one. + expect(within(row1).getAllByText("100")).toHaveLength(2); // totalEggs, ratedEggs within(row1).getByText("2/3/5"); // cracked/dirty/discarded within(row1).getByText("90"); // sellable - within(row1).getByText("98"); // henDays + // #780 — Hen-days and Recorded are adjacent and equal on a fully recorded + // day, which is the point: the gap between them is what a period is + // missing, and eggs ÷ Recorded has to reproduce the percentage beside it. + expect(within(row1).getAllByText("98")).toHaveLength(2); // henDays, recordedHenDays within(row1).getByText("91.8"); // henDayPct // #650 — figures are numeric cells: right-aligned tabular nowrap (styles.num.test.ts // pins what the class does; this pins that the screen puts it on the figure and // its header, and keeps it off the date). - expect(within(row1).getByText("100")).toHaveClass("num"); + for (const cell of within(row1).getAllByText("100")) expect(cell).toHaveClass("num"); expect(within(row1).getByText("07/19/2026")).not.toHaveClass("num"); expect(screen.getByRole("columnheader", { name: "Eggs" })).toHaveClass("num"); @@ -104,9 +110,9 @@ describe("ReportsPage production section (renders for every role)", () => { within(row2).getByText("—"); // null henDayPct falls back to the em dash const periodRow = screen.getByRole("row", { name: /Period/ }); - within(periodRow).getByText("195"); // totalEggs + expect(within(periodRow).getAllByText("195")).toHaveLength(2); // totalEggs, totalRatedEggs within(periodRow).getByText("181"); // totalSellable - within(periodRow).getByText("196"); // totalHenDays + expect(within(periodRow).getAllByText("196")).toHaveLength(2); // totalHenDays, totalRecordedHenDays within(periodRow).getByText("92.3"); // periodHenDayPct expect(screen.getByText("By grade: Grade A 60, Grade B 30")).toBeInTheDocument(); diff --git a/web/src/routes/ReportsPage.tsx b/web/src/routes/ReportsPage.tsx index c1c7a3f79..9267c6896 100644 --- a/web/src/routes/ReportsPage.tsx +++ b/web/src/routes/ReportsPage.tsx @@ -120,7 +120,17 @@ export function ReportsPage() { hand-graded remainder, Condition is what the cracked/dirty counters contributed as stock. */} {t("conditionHeader")} - {t("deathsHeader")}{t("henDaysHeader")}{t("henDayPctHeader")} + {t("deathsHeader")}{t("henDaysHeader")} + {/* #780 — the percentage's own numerator and denominator. Eggs + ÷ Hen-days stopped reproducing Hen-day %: Hen-days is every + bird alive, the rate divides by the flocks that recorded, + and its numerator excludes any flock whose birds it excludes. + Showing only one half left the row inviting a division that + gives the wrong answer. The gap between Hen-days and Recorded + is what the period is missing. */} + {t("recordedHenDaysHeader")} + {t("ratedEggsHeader")} + {t("henDayPctHeader")} @@ -133,6 +143,8 @@ export function ReportsPage() { {fmt.count(d.fromCounts)} {fmt.count(d.deaths)} {fmt.count(d.henDays)} + {fmt.count(d.recordedHenDays)} + {fmt.count(d.ratedEggs)} {d.henDayPct === null ? "—" : fmt.count(d.henDayPct, 1)} ))} @@ -146,6 +158,8 @@ export function ReportsPage() { {fmt.count(production.totalFromCounts)} {fmt.count(production.totalDeaths)} {fmt.count(production.totalHenDays)} + {fmt.count(production.totalRecordedHenDays)} + {fmt.count(production.totalRatedEggs)} {production.periodHenDayPct === null ? "—" : fmt.count(production.periodHenDayPct, 1)} diff --git a/web/src/styles.css b/web/src/styles.css index 14c0c555d..736b65863 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1807,14 +1807,20 @@ 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; } +/* The caption wraps rather than splitting the title: `tl` runs roughly 30% + longer than `en` (#688) and on a 331px panel "Itlog bawat araw" beside two + figures does not fit on one line. Keeping the figures together and letting + the row wrap degrades better than hyphen-free mid-title wrapping. */ .trend-scale { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 0.5rem; @@ -1823,12 +1829,54 @@ 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; + white-space: nowrap; +} + +/* 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,34 +1884,144 @@ 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) 55%, 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%; max-width: 18px; + /* The 2% floor is 1.58px on a 5rem strip, and that sliver is the entire + visible difference between "recorded zero" and "nobody looked". A pixel + floor makes it a mark a reader can actually see. */ + min-height: 3px; border-radius: 3px 3px 0 0; background: var(--stat-accent); } +/* The selected day. The SLOT is marked, not just the bar: many 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. + + Outlined and NOT filled. A fill was the approved mockup and it was wrong on + the running app: tinting the slot to 14% ink 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 exact misreading this issue exists to remove. Caught by + looking at the render, not at the diff. + + The ring sits OUTSIDE the slot. Inset, it was invisible on a tall day: a bar + is 18px in a ~20px slot, so an inset band lands on the bar, and `.day.on > i` + has just painted that bar the ring's own colour — the peak day showed no ring + at all. 1px out sits inside the 4px gap between slots, so it cannot touch a + neighbour, and it reads on every day whatever the bar's height. */ +.day.on { + outline: 1.5px solid var(--ink); + outline-offset: 1px; +} + +.day.on > i { + background: var(--ink); +} + +/* A day only some houses reported. The bar is a FLOOR, not the day's output, so + it is drawn hollow: a solid bar here asserts a drop in production that the + farm's own records never claimed — the same mistake as drawing an unrecorded + day as a zero, one step in. */ +.day-partial > i { + background: linear-gradient( + 135deg, + var(--stat-accent) 25%, + transparent 25%, + transparent 50%, + var(--stat-accent) 50%, + var(--stat-accent) 75%, + transparent 75% + ); + background-size: 6px 6px; + border: 1px solid var(--stat-accent); + box-sizing: border-box; +} + +.day.on.day-partial > i { + background: linear-gradient( + 135deg, + var(--ink) 25%, + transparent 25%, + transparent 50%, + var(--ink) 50%, + var(--ink) 75%, + transparent 75% + ); + background-size: 6px 6px; + border: 1px solid var(--ink); + box-sizing: border-box; +} + +/* 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%; + 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 5px 0` — NO bottom border, and this is the whole fix. + The visible triangle is the TOP border, so a uniform 5px box carries 5px of + empty bottom border BELOW it; with `bottom: 100%` pinning the box's bottom + edge to the slot, that empty half pushed the triangle 5px higher, up behind + the readout, leaving about 1px of a 5px arrow showing in the same colour as + the box it was hiding under. Measured, not guessed — and the comment that + used to sit here asserted the opposite. With no bottom border the element + IS the triangle: it spans the 5px directly above the slot, inside the 8px + the dock reserves. */ + border-width: 5px 5px 0; + 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..df5a7cf70 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,9 @@ 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-partial > i", ".day.on.day-partial > i", + ".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); @@ -312,6 +314,51 @@ describe("dashboard surfaces (#654, INV-8)", () => { } } }); + // #780 — the arrow under the day readout shipped 4/5ths hidden behind the box + // it points from, and every guard here stayed green: the selector was + // declared and its colours were tokenised, which is all this file used to + // ask. Both facts that made it wrong are asserted now. + // + // A CSS triangle's visible wedge is its TOP border, so a bottom border is + // 5px of invisible box between the wedge and what it points at — with + // `bottom: 100%` pinning the box to the slot, that gap pushes the wedge up + // behind the readout. The third side must be zero. + it("points the day readout's arrow at the slot, with no bottom border to push it off", () => { + const arrow = bodyOf(".day.on::after"); + expect(arrow).toMatch(/bottom:\s*100%/); + // A visible wedge: the top border carries the ink, the sides are clear. + expect(arrow).toMatch(/border-top-color:\s*var\(--ink\)/); + expect(arrow).toMatch(/border-color:\s*transparent/); + // Three values, the last a bare 0 — `5px 5px 0`. A fourth value, or a + // single one, reinstates the bottom border and the arrow disappears again. + const width = /border-width:\s*([^;]+)/.exec(arrow)?.[1].trim(); + expect(width, "border-width must be the three-value form").toBeDefined(); + const parts = width!.split(/\s+/); + expect(parts).toHaveLength(3); + expect(parts[2], "the bottom border must be 0 or the arrow hides behind the box").toBe("0"); + // Nothing may move it back up: `bottom: 100%` means a positive + // `margin-bottom` pushes AWAY from the slot, which is how this shipped. + expect(arrow).not.toMatch(/margin-bottom/); + }); + + // The selection ring must not be drawn inside the slot: a bar is 18px in a + // ~20px slot and `.day.on > i` paints it the ring's own colour, so an inset + // ring vanished on the peak day — the one most likely to be inspected. + it("draws the selected day's ring outside the slot, clear of its own bar", () => { + const offset = /outline-offset:\s*(-?[\d.]+)px/.exec(bodyOf(".day.on"))?.[1]; + expect(offset, "outline-offset must be declared in px").toBeDefined(); + expect(Number(offset)).toBeGreaterThan(0); + // And inside the 4px inter-slot gap, so two adjacent rings cannot touch. + expect(Number(offset)).toBeLessThan(2); + }); + + // A recorded day that produced nothing is a 2% bar, which is 1.58px on a 5rem + // strip — the entire visible difference between "the flock laid nothing" and + // "nobody looked". A percentage floor alone is not a legible mark. + it("gives the shortest bar a pixel floor, not only a percentage one", () => { + expect(bodyOf(".day > i")).toMatch(/min-height:\s*[3-9]px/); + }); + it("keeps the tile on the card radius and the production bar on the accent token", () => { expect(bodyOf(".capture-tile")).toMatch(/border-radius:\s*var\(--r-card\)/); expect(bodyOf(".capture-tile")).toMatch(/border:\s*1px solid var\(--hairline\)/);