Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
71ab14c
fix(web): scale the 14-day strip to the partial peak when no day is c…
mforce Sep 20, 2026
daa43b9
feat(api): scope the production report to one flock, server-side (#916)
mforce Sep 20, 2026
f7cf692
feat(web): add a flock scope selector to the Dashboard Lay rate card …
mforce Sep 20, 2026
a3d1055
fix(web): close the flock picker on commit, meet the 44px target, fix…
mforce Sep 20, 2026
911e469
chore: deslop the flock-scope branch
mforce Sep 20, 2026
00f7501
fix(web): match the approved mockup exactly, fold in Codex review fin…
mforce Sep 20, 2026
d191b73
fix(web): reach past the picker's first page, key failure outcomes by…
mforce Sep 20, 2026
6042680
refactor(web): extract the flock picker dialog, guard its discovery g…
mforce Sep 20, 2026
fb6f37e
test(web): restore broader picker keyboard coverage and guard load-mo…
mforce Sep 20, 2026
1662c7e
chore(web): deslop the Lay rate scope comments
mforce Sep 21, 2026
b67aba3
chore(web): trim the long comment blocks the first deslop left
mforce Sep 21, 2026
729c671
chore(web): cut branch-added comment blocks down to paragraph length …
mforce Sep 21, 2026
b8f99ed
docs(test): correct a stale claim that the flock picker filters clien…
mforce Sep 21, 2026
6c27819
chore(web): cut the duplicated comment slop the block-length pass missed
mforce Sep 21, 2026
473f4c7
fix(web): stop a flock scope pick from changing the Today panel, fix …
mforce Sep 21, 2026
39ffd3a
fix(web): fold the dashboard's two trend requests into one, and fix a…
mforce Sep 21, 2026
624b9b3
fix(web): replace the bespoke flock-scope dialog with the shared Name…
mforce Sep 21, 2026
f1837a3
fix(web): mark the reopened flock-scope picker's active choice (#918 P2)
mforce Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,27 @@ 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.

**14-day strip scale (#916)** — Peak scales every bar to the largest
**complete** day's total when the window has one. When it does not — every
recorded day in the window is partial — Peak falls back to the largest
**partial** day's total instead, and the caption says "partial days only" so
the bars are never read as full-farm output. A window with no recorded day
at all has no Peak and draws every bar at the 2% floor's absence (nothing to
scale against). The Avg line never falls back: it stays complete-day-only in
every case, because averaging a floor understates whatever the missing
houses would have added.

**Lay rate flock scope (#916)** — the Dashboard's Lay rate card reads one
scope: **All flocks** (every flock the signed-in user can see) or one
selected flock, chosen from a searchable picker. The scope applies to the
whole card together — the strip, completeness, the complete-day average, and
both hen-day comparison periods — never to only part of it, and the figures
come from a server-scoped report, never from filtering the farm-wide one in
the browser. With exactly one accessible flock the card shows that flock's
name as plain text instead of a picker, and its figures equal choosing that
same flock from a longer list. Other Dashboard panels do not follow this
scope.

**Production report (#91)** — per-day official production over a range
(Draft entries aren't submitted, Voided ones vacated their day — neither
counts): eggs, losses, sellable, deaths, hen-day %, period totals, grade
Expand Down
19 changes: 15 additions & 4 deletions src/Cluckwork.Api/Endpoints/Reports/ReportEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace Cluckwork.Api.Endpoints.Reports;

using Cluckwork.Application.Common;
using Cluckwork.Application.Features.Flocks;
using Cluckwork.Application.Features.Reports;
using Cluckwork.Infrastructure.Persistence;

Expand All @@ -20,7 +21,7 @@ public static RouteGroupBuilder MapReportEndpoints(this RouteGroupBuilder group)

group.MapGet("/production", Production)
.WithName("ProductionReport")
.WithSummary("Per-day production across the range: eggs, losses, sellable, deaths, hen-day % (spec §19.3), plus period totals and grade breakdown.");
.WithSummary("Per-day production across the range: eggs, losses, sellable, deaths, hen-day % (spec §19.3), plus period totals and grade breakdown. Optional flockId narrows every figure to one flock.");

group.MapGet("/sales", Sales)
.WithName("SalesSummaryReport")
Expand Down Expand Up @@ -66,13 +67,23 @@ public static RouteGroupBuilder MapReportEndpoints(this RouteGroupBuilder group)
}

private static async Task<IResult> Production(
IReportQueries reports, TenantContext tenant, IFarmClock farmClock, CancellationToken ct,
DateOnly? from = null, DateOnly? to = null)
IReportQueries reports, IFlockRepository flocks, TenantContext tenant,
IFarmClock farmClock, CancellationToken ct,
DateOnly? from = null, DateOnly? to = null, Guid? flockId = null)
{
if (!tenant.IsResolved) return Results.Unauthorized();
var bad = ValidateRange(await farmClock.TodayAsync(ct), from, to, out var f, out var t);
if (bad is not null) return bad;
return Results.Ok(await reports.GetProductionAsync(f, t, ct));
// #916 — the same existence check flock detail uses, and for the same
// reason it is enough: `GetByIdAsync` reads through the model's
// structural `AccountId AND flock-scope` query filters (#613), so
// another farm's flock, a flock outside this Worker's scope, and a
// flock that never existed all resolve to null and all answer 404.
// Which of the three it was is deliberately not distinguishable.
// Nothing here is a write, so `FlockScopeGuard` does not apply (#787).
if (flockId is not null && await flocks.GetByIdAsync(flockId.Value, ct) is null)
return Results.NotFound();
return Results.Ok(await reports.GetProductionAsync(f, t, flockId, ct));
}

private static async Task<IResult> Sales(
Expand Down
12 changes: 11 additions & 1 deletion src/Cluckwork.Application/Features/Reports/IReportQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ public interface IReportQueries
{
// Production (spec §19.3 hen-day %): official entries only — Draft isn't
// submitted yet, Voided vacated its day (#82).
Task<ProductionReport> GetProductionAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
//
// `flockId` narrows every figure to one flock, INCLUDING the hen-day
// exposure, so the rate's numerator and denominator stay the same flock's
// (#916). It is a query parameter, not a post-filter: a caller cannot
// reconstruct a scoped `HenDayPct` from the farm-wide payload, because the
// rate is computed per day from a denominator the payload only carries
// summed over every flock. null = farm-wide, byte-for-byte as before.
// Visibility is the ENDPOINT's job — this is a report query, not an
// authorization boundary.
Task<ProductionReport> GetProductionAsync(
DateOnly from, DateOnly to, Guid? flockId = null, CancellationToken ct = default);

// Money summaries — the callers gate these behind AdminOnly.
Task<SalesSummary> GetSalesAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
Expand Down
25 changes: 19 additions & 6 deletions src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ public sealed class ReportQueries(AppDbContext db) : IReportQueries
[DailyEntryStatus.Submitted, DailyEntryStatus.Locked, DailyEntryStatus.ManagerAdjusted];

public async Task<ProductionReport> GetProductionAsync(
DateOnly from, DateOnly to, CancellationToken ct = default)
DateOnly from, DateOnly to, Guid? flockId = null, CancellationToken ct = default)
{
// #916 — every query below carries the same `flockId == null || …`
// conjunct, so a null scope leaves the SQL semantically what it was and
// a set scope narrows the eggs and the EXPOSURE together. Narrowing one
// without the other is the #780 defect in miniature: a farm-wide
// denominator under one flock's eggs rates that flock at a fraction of
// its real lay. The bird-movement queries below need no conjunct —
// they are already bounded by `flockIds`, which this filter shrinks.
//
// 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:
Expand All @@ -31,7 +39,8 @@ public async Task<ProductionReport> GetProductionAsync(
// 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))
.Where(e => e.Date >= from && e.Date <= to && OfficialStatuses.Contains(e.Status)
&& (flockId == null || e.FlockId == flockId.Value))
.GroupBy(e => new { e.Date, e.FlockId, e.HouseId })
.Select(g => new
{
Expand Down Expand Up @@ -67,7 +76,8 @@ public async Task<ProductionReport> GetProductionAsync(
var gradeTotals = await db.DailyEntryGrades
.Where(g => db.DailyEntries.Any(e =>
e.Id == g.DailyEntryId && e.Date >= from && e.Date <= to
&& OfficialStatuses.Contains(e.Status)))
&& OfficialStatuses.Contains(e.Status)
&& (flockId == null || e.FlockId == flockId.Value)))
.GroupBy(g => g.EggGradeId)
.Select(g => new { EggGradeId = g.Key, Quantity = g.Sum(x => x.Quantity) })
.ToListAsync(ct);
Expand All @@ -93,14 +103,16 @@ public async Task<ProductionReport> GetProductionAsync(
// which reads as an empty grade rather than as absence.
var crackedTotals = await db.DailyEntries
.Where(e => e.Date >= from && e.Date <= to && OfficialStatuses.Contains(e.Status)
&& e.CrackedGradeId != null && e.CrackedEggs > 0)
&& e.CrackedGradeId != null && e.CrackedEggs > 0
&& (flockId == null || e.FlockId == flockId.Value))
.GroupBy(e => e.CrackedGradeId!.Value)
.Select(g => new { EggGradeId = g.Key, Quantity = g.Sum(e => e.CrackedEggs) })
.ToListAsync(ct);

var dirtyTotals = await db.DailyEntries
.Where(e => e.Date >= from && e.Date <= to && OfficialStatuses.Contains(e.Status)
&& e.DirtyGradeId != null && e.DirtyEggs > 0)
&& e.DirtyGradeId != null && e.DirtyEggs > 0
&& (flockId == null || e.FlockId == flockId.Value))
.GroupBy(e => e.DirtyGradeId!.Value)
.Select(g => new { EggGradeId = g.Key, Quantity = g.Sum(e => e.DirtyEggs) })
.ToListAsync(ct);
Expand Down Expand Up @@ -140,7 +152,8 @@ public async Task<ProductionReport> GetProductionAsync(
var flocks = await db.Flocks
.Where(f => f.PlacementDate <= to
&& (f.DepletedOn == null || f.DepletedOn >= from)
&& (f.ArchivedOn == null || f.ArchivedOn >= from))
&& (f.ArchivedOn == null || f.ArchivedOn >= from)
&& (flockId == null || f.Id == flockId.Value))
.Select(f => new
{
f.Id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public async Task GetProductionAsync_HonoursAnAlreadyCancelledToken()

await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
reportQueries.GetProductionAsync(
Today.AddDays(-1), Today, new CancellationToken(canceled: true)));
Today.AddDays(-1), Today, ct: new CancellationToken(canceled: true)));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ public sealed class GateReportQueries(
IReportQueries inner, TenantContext tenant, ReportGate gate) : IReportQueries
{
public async Task<ProductionReport> GetProductionAsync(
DateOnly from, DateOnly to, CancellationToken ct = default)
DateOnly from, DateOnly to, Guid? flockId = null, CancellationToken ct = default)
{
await gate.WaitIfArmedForAsync(tenant.AccountId, ct);
return await inner.GetProductionAsync(from, to, ct);
return await inner.GetProductionAsync(from, to, flockId, ct);
}

public Task<SalesSummary> GetSalesAsync(DateOnly from, DateOnly to, CancellationToken ct = default) =>
Expand Down
138 changes: 138 additions & 0 deletions tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,144 @@ await factory.WithTenantScopeAsync(accountId, async db =>
Assert.Equal(100, report.Days[2].HenDays); // gone the day after
}

// #916 — the Dashboard's Lay rate card gains a flock selector, so the
// report has to scope server-side. The eggs AND the exposure move together:
// filtering a farm-wide payload client-side gives the scoped flock's eggs
// over every flock's hen-days, which rates a healthy flock at a fraction of
// its real lay. This asserts the scoped figures against the hand-computed
// per-flock arithmetic, not against a re-derivation of the same code.
[Fact]
public async Task Production_ScopedToOneFlock_ReportsThatFlocksEggsOverItsOwnExposure()
{
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 of 100 birds each, placed 30 days ago.
var flockA = await factory.SeedFlockAsync(accountId, farmId);
var flockB = await factory.SeedFlockAsync(accountId, farmId);
var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email));
var day = Today.AddDays(-1);

async Task RecordAsync(Guid flockId, int total, int deaths)
{
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 = deaths,
grades = new[] { new { eggGradeId = grades["Large"], quantity = total } }
});
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var id = (await response.Content.ReadFromJsonAsync<Created>())!.Id;
Assert.Equal(HttpStatusCode.OK, (await client.PostWithKeyAsync(
$"/api/v1/daily-entries/{id}/submit", Guid.NewGuid().ToString())).StatusCode);
}

await RecordAsync(flockA, 80, deaths: 3);
await RecordAsync(flockB, 40, deaths: 1);

var range = $"from={day:yyyy-MM-dd}&to={day:yyyy-MM-dd}";
var farmWide = await client.GetFromJsonAsync<ProductionDto>(
$"/api/v1/reports/production?{range}");
var scoped = await client.GetFromJsonAsync<ProductionDto>(
$"/api/v1/reports/production?{range}&flockId={flockA}");

// Unchanged without the parameter: both flocks, both exposures.
var whole = farmWide!.Days.Single();
Assert.Equal((120, 4), (whole.TotalEggs, whole.Deaths));
Assert.Equal((2, 2), (whole.RecordedFlocks, whole.ExpectedFlocks));
Assert.Equal((200L, 200L), (whole.HenDays, whole.RecordedHenDays));
Assert.Equal(60m, whole.HenDayPct); // 120 / 200

// Scoped: flock A's eggs only, and flock B's 100 birds are gone from the
// denominator too. Client-side filtering would leave 80 over 200 = 40%.
var only = scoped!.Days.Single();
Assert.Equal((80, 3), (only.TotalEggs, only.Deaths));
Assert.Equal((1, 1, 0), (only.RecordedFlocks, only.ExpectedFlocks, only.MissingFlocks));
Assert.Equal((100L, 100L, 80), (only.HenDays, only.RecordedHenDays, only.RatedEggs));
Assert.Equal(80m, only.HenDayPct);

// Period totals and the grade breakdown scope with it — the "By grade"
// table must not keep summing the other flock's eggs under the header.
Assert.Equal((80, 80), (scoped.TotalEggs, scoped.TotalSellable));
Assert.Equal((100L, 100L, 80), (scoped.TotalHenDays, scoped.TotalRecordedHenDays, scoped.TotalRatedEggs));
Assert.Equal(80m, scoped.PeriodHenDayPct);
Assert.Equal(80, Assert.Single(scoped.GradeTotals).Quantity);
Assert.Equal(120, Assert.Single(farmWide.GradeTotals).Quantity);

// Scoping to B gives B's figures, so neither answer is the accident of
// one flock happening to be the whole farm.
var other = (await client.GetFromJsonAsync<ProductionDto>(
$"/api/v1/reports/production?{range}&flockId={flockB}"))!.Days.Single();
Assert.Equal((40, 1), (other.TotalEggs, other.Deaths));
Assert.Equal(40m, other.HenDayPct);
// The two scoped reports partition the farm-wide one.
Assert.Equal(whole.TotalEggs, only.TotalEggs + other.TotalEggs);
Assert.Equal(whole.HenDays, only.HenDays + other.HenDays);
}

// #916 — the selector must not become a discovery channel. A flock outside
// the caller's flock scope is 404, the same answer flock detail gives
// (#388), rather than an empty report (which says "this flock exists and
// filed nothing") or a farm-wide one (which leaks the other flock's eggs).
[Fact]
public async Task Production_FlockIdOutsideTheCallersScope_Returns404()
{
var ownerEmail = $"o-{Guid.NewGuid():N}@test.local";
var accountId = await factory.SeedAccountWithUserAsync(ownerEmail);
var farmId = Guid.NewGuid();
var assigned = await factory.SeedFlockAsync(accountId, farmId);
var unassigned = await factory.SeedFlockAsync(accountId, farmId);

var workerEmail = $"w-{Guid.NewGuid():N}@test.local";
await factory.SeedUserAsync(accountId, workerEmail, (string?)null);
var workerId = await factory.WithTenantScopeAsync(accountId, db =>
db.Users.Where(u => u.AccountId == accountId && u.Email == workerEmail)
.Select(u => u.Id).SingleAsync());
await factory.WithTenantScopeAsync(accountId, async db =>
{
db.UserRoleAssignments.Add(Domain.Accounts.UserRoleAssignment.Create(
Guid.NewGuid(), accountId, workerId, farmId: null, houseId: null, flockId: assigned));
await db.SaveChangesAsync();
});
var worker = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(workerEmail));

var range = $"from={Today:yyyy-MM-dd}&to={Today:yyyy-MM-dd}";
Assert.Equal(HttpStatusCode.NotFound, (await worker.GetAsync(
$"/api/v1/reports/production?{range}&flockId={unassigned}")).StatusCode);
// Positive control: the scope guard is answering, not the parameter
// being rejected outright.
Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync(
$"/api/v1/reports/production?{range}&flockId={assigned}")).StatusCode);
// And the unscoped report still works for the same caller.
Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync(
$"/api/v1/reports/production?{range}")).StatusCode);
}

// #916 — same answer across a tenant boundary, and an id that names nothing
// at all. All three "cannot see it" cases are 404 and are deliberately
// indistinguishable: telling them apart would confirm another farm's flock
// id exists.
[Fact]
public async Task Production_FlockIdFromAnotherFarmOrNowhere_Returns404()
{
var mineEmail = $"u-{Guid.NewGuid():N}@test.local";
var mine = await factory.SeedAccountWithUserAsync(mineEmail);
var theirs = await factory.SeedAccountWithUserAsync($"u-{Guid.NewGuid():N}@test.local");
var theirFlock = await factory.SeedFlockAsync(theirs, Guid.NewGuid());
var myFlock = await factory.SeedFlockAsync(mine, Guid.NewGuid());
var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(mineEmail));

var range = $"from={Today:yyyy-MM-dd}&to={Today:yyyy-MM-dd}";
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(
$"/api/v1/reports/production?{range}&flockId={theirFlock}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(
$"/api/v1/reports/production?{range}&flockId={Guid.NewGuid()}")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync(
$"/api/v1/reports/production?{range}&flockId={myFlock}")).StatusCode);
}

[Fact]
public async Task SalesAndProfit_SummariesMatchLedger()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
| GeneralInventory | — | R (3) | R (3) fk:3 | R (2) fk:2 | — | — | — | — | P |
| Finance | — | R (2) | R (2) fk:1 | — | — | — | — | — | P |
| Insights | — | — | — | — | — | — | — | — | P |
| Platform | A (43) | A (18) | A (22) | A (24) | A (31) | A (18) | A (9) | A (25) | — |
| Platform | A (43) | A (18) | A (23) | A (24) | A (31) | A (18) | A (9) | A (25) | — |

## Cross-owner foreign keys

Expand Down Expand Up @@ -58,7 +58,7 @@ After-commit events and read-model queries are not syntactically observable, and
| Insights | Platform | Q/P | P |
| Platform/adapters | Access | W | A (43) |
| Platform/adapters | Farm | W | A (18) |
| Platform/adapters | FlockManagement | W | A (22) |
| Platform/adapters | FlockManagement | W | A (23) |
| Platform/adapters | EggOperations | W | A (24) |
| Platform/adapters | Commerce | W | A (31) |
| Platform/adapters | GeneralInventory | W | A (18) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@
{
"symbol": "Cluckwork.Api.Endpoints.Reports.ReportEndpoints.Production",
"reaches": [
"FlockManagement",
"Insights"
]
},
Expand Down
Loading
Loading