Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions src/Cluckwork.Api/Endpoints/Stock/StockEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ public static RouteGroupBuilder MapStockEndpoints(this RouteGroupBuilder group)

private static async Task<IResult> ListLots(
IEggLotRepository eggLots, TenantContext tenant, CancellationToken ct,
Guid? gradeId = null, int? limit = null, int? offset = null)
Guid? gradeId = null, DateOnly? from = null, DateOnly? to = null,
int? limit = null, int? offset = null)
{
if (!tenant.IsResolved) return Results.Unauthorized();
var take = Math.Clamp(limit ?? DefaultPageSize, 1, MaxPageSize);
var skip = Math.Max(offset ?? 0, 0);
var lots = await eggLots.ListAsync(gradeId, take, skip, ct);
var lots = await eggLots.ListAsync(gradeId, from, to, take, skip, ct);
return Results.Ok(lots.Select(l => new EggLotResponse(
l.Id, l.EggGradeId, l.ProductionDate, l.QuantityProduced,
l.QuantityAvailable, l.RestrictedUntil, l.DailyEntryId)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ Task<IReadOnlyList<EggLot>> GetByDailyEntryLockedAsync(
Guid accountId, Guid dailyEntryId, CancellationToken ct = default);

// Read-only lot listing for the stock drill-down (#101), newest production
// first, optionally filtered by grade. Capped by limit — the SPA pages.
// first, optionally filtered by grade and/or an inclusive production-date
// window (#465). Capped by limit — the SPA pages.
Task<IReadOnlyList<EggLot>> ListAsync(
Guid? eggGradeId, int limit, int offset, CancellationToken ct = default);
Guid? eggGradeId, DateOnly? from, DateOnly? to,
int limit, int offset, CancellationToken ct = default);
}

public sealed record StockByGrade(
Expand Down
13 changes: 8 additions & 5 deletions src/Cluckwork.Infrastructure/Persistence/DemoDataSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,15 @@ private async Task SeedDemoAsync(Guid accountId, CancellationToken ct)
Check(old.Deplete(today.AddDays(-30)));
await db.SaveChangesAsync(ct);

// --- A week of submitted entries per active flock. Deterministic
// variation (no Random: reproducible demos). Today stays unrecorded for
// House 2 so the dashboard shows the "no entry" flag.
foreach (var (flockId, baseline) in new[] { (house1, 430), (house2, 350) })
// --- Submitted entries per active flock, deterministic variation (no
// Random: reproducible demos). House 1 carries ~8 months of history so
// every per-grade lot list OUT-PAGES the stock drill-down's 50-lot
// page (#465 — the load-more pager and date filter are exercisable
// straight from a demo farm); House 2 keeps a single week. Today stays
// unrecorded for House 2 so the dashboard shows the "no entry" flag.
foreach (var (flockId, baseline, days) in new[] { (house1, 430, 240), (house2, 350, 7) })
{
for (var d = 7; d >= 0; d--)
for (var d = days; d >= 0; d--)
{
if (d == 0 && flockId == house2) continue;
var date = today.AddDays(-d);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ public sealed class EggLotRepository(AppDbContext db) : IEggLotRepository
db.EggLots.FirstOrDefaultAsync(e => e.Id == id, ct);

public async Task<IReadOnlyList<EggLot>> ListAsync(
Guid? eggGradeId, int limit, int offset, CancellationToken ct = default) =>
Guid? eggGradeId, DateOnly? from, DateOnly? to,
int limit, int offset, CancellationToken ct = default) =>
await db.EggLots
.AsNoTracking()
.Where(l => eggGradeId == null || l.EggGradeId == eggGradeId)
.Where(l => from == null || l.ProductionDate >= from)
.Where(l => to == null || l.ProductionDate <= to)
.OrderByDescending(l => l.ProductionDate).ThenByDescending(l => l.Id)
.Skip(offset)
.Take(limit)
Expand Down
9 changes: 9 additions & 0 deletions tests/Cluckwork.Api.IntegrationTests/DemoSeedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ public async Task DemoSeed_PopulatesEveryScreen_AndIsIdempotent()
var entries = await client.GetFromJsonAsync<List<object>>("/api/v1/daily-entries");
Assert.True(entries!.Count >= 14, $"expected a week of entries, got {entries.Count}");

// #465 — the demo must OUT-PAGE the stock drill-down (50/page), so the
// load-more pager and date filter are exercisable straight from a
// demo-seeded farm. Per grade, because the panel filters by grade.
var largeId = stock!.Single(s => s.GradeName == "Large").EggGradeId;
var largeLots = await client.GetFromJsonAsync<List<object>>(
$"/api/v1/stock/lots?gradeId={largeId}&limit=200");
Assert.True(largeLots!.Count > 50,
$"expected the Large grade to out-page the 50-lot stock page, got {largeLots.Count}");

// A second SeedAsync call against the same database is a no-op —
// DemoDataSeeder's own empty-catalog guard fires.
using (var seedScope2 = factory.Services.CreateScope())
Expand Down
34 changes: 34 additions & 0 deletions tests/Cluckwork.Api.IntegrationTests/EggLedgerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,40 @@ await client.PostWithKeyAsync($"/api/v1/sales/{orderId}/items", Guid.NewGuid().T
Assert.Equal("Production", movements![0].MovementType);
}

// #465 — the SPA's date filter: from/to bound the lot list by production
// date (inclusive both ends, like the daily-entries list), composing with
// gradeId and paging so "show more" pages within the filtered window.
[Fact]
public async Task DateFilter_BoundsTheLotList_InclusiveAndPageable()
{
var (client, _, farmId, flockId, gradeId, _) = await SetupAsync();
var (_, oldest, _) = await SubmitEntryAsync(client, farmId, flockId, gradeId, 10, Today.AddDays(-2));
var (_, middle, _) = await SubmitEntryAsync(client, farmId, flockId, gradeId, 20, Today.AddDays(-1));
var (_, newest, _) = await SubmitEntryAsync(client, farmId, flockId, gradeId, 30);

async Task<List<Guid>> IdsAsync(string query) =>
(await client.GetFromJsonAsync<List<LotRow>>($"/api/v1/stock/lots?gradeId={gradeId}&{query}"))!
.Select(l => l.Id).ToList();

// from alone drops older lots; to alone drops newer ones.
Assert.Equal([newest, middle], await IdsAsync($"from={Today.AddDays(-1):yyyy-MM-dd}"));
Assert.Equal([oldest], await IdsAsync($"to={Today.AddDays(-2):yyyy-MM-dd}"));

// Both bounds are inclusive: a one-day window keeps exactly that day.
Assert.Equal([middle],
await IdsAsync($"from={Today.AddDays(-1):yyyy-MM-dd}&to={Today.AddDays(-1):yyyy-MM-dd}"));

// Paging happens WITHIN the window: two disjoint pages of one stitch
// back to the filtered pair, newest first.
var page1 = await IdsAsync($"from={Today.AddDays(-1):yyyy-MM-dd}&limit=1&offset=0");
var page2 = await IdsAsync($"from={Today.AddDays(-1):yyyy-MM-dd}&limit=1&offset=1");
Assert.Equal([newest], page1);
Assert.Equal([middle], page2);

// An empty window is an empty list, not an error.
Assert.Empty(await IdsAsync($"from={Today.AddDays(10):yyyy-MM-dd}"));
}

[Fact]
public async Task LotMovements_AreTenantIsolated_ForeignLotLooksNonexistent()
{
Expand Down
4 changes: 3 additions & 1 deletion tools/simulation/ui/mutation-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ declare -A SPEC_FOR=(
[audit-gate-removed]="specs/readonly.spec.ts"
[users-gate-removed]="specs/readonly.spec.ts"
[flock-scope-removed]="specs/worker.spec.ts"
[stock-pager-inert]="specs/readonly.spec.ts"
[stock-summary-broken]="specs/owner.spec.ts"
[report-range-bound-removed]="specs/reports-range.spec.ts"
[refresh-always-fails]="specs/session-refresh.spec.ts"
Expand All @@ -72,6 +73,7 @@ declare -A GREP_FOR=(
[audit-gate-removed]="direct link to /audit"
[users-gate-removed]="direct link to /users"
[flock-scope-removed]="is refused a daily entry"
[stock-pager-inert]="pages a deep grade"
[stock-summary-broken]="dashboard shows real production"
[report-range-bound-removed]="refuses one day beyond"
[refresh-always-fails]="forces a 401"
Expand All @@ -89,7 +91,7 @@ declare -A FALSE_KILLS=(
MUTANTS=("$@")
if [ ${#MUTANTS[@]} -eq 0 ]; then
MUTANTS=(audit-gate-removed users-gate-removed flock-scope-removed
stock-summary-broken report-range-bound-removed
stock-pager-inert stock-summary-broken report-range-bound-removed
refresh-always-fails logout-not-honoured
nav-role-gate-bypassed payment-never-settles export-returns-nothing)
fi
Expand Down
10 changes: 10 additions & 0 deletions tools/simulation/ui/specs/manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,16 @@ test.describe("Manager", () => {
// never restated the day's laying.
await expect(lotRow.getByRole("cell", { name: String(eggs - 2), exact: true })).toBeVisible();
await expect(lotRow.getByRole("cell", { name: String(eggs), exact: true })).toBeVisible();

// ---- #465: the date filter reaches lots server-side -------------------
// A window that cannot contain this lot empties the table…
await page.getByLabel(tEn("stock:fromLabel"), { exact: true }).fill("2000-01-01");
await page.getByLabel(tEn("stock:toLabel"), { exact: true }).fill("2000-01-02");
await expect(page.getByText(tEn("stock:noLotsMessage"))).toBeVisible();
// …and narrowing to exactly today brings it back, corrected balance intact.
await page.getByLabel(tEn("stock:fromLabel"), { exact: true }).fill(today);
await page.getByLabel(tEn("stock:toLabel"), { exact: true }).fill(today);
await expect(lotRow.getByRole("cell", { name: String(eggs - 2), exact: true })).toBeVisible();
});

test("can reach the admin destinations a Worker cannot", async ({ signIn, nav }) => {
Expand Down
21 changes: 21 additions & 0 deletions tools/simulation/ui/specs/readonly.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ test.describe("ReadOnly", () => {
await expect(page.getByRole("heading", { name: tEn("reports:moneyHeading") })).toBeHidden();
});

// #465 — the fixture's 90-day × 2-flock history gives every graded grade
// ~176 lots, so the drill-down MUST page: exactly one 50-lot page first,
// then load-more appends the next. Growth-after-click is the load-bearing
// assertion — the stock-pager-inert mutant serves page one for every
// offset, and only the count reaching 100 catches it.
test("pages a deep grade's lots with load more (#465)", async ({ page, nav }) => {
await nav.link("nav:stock").click();
await page.getByRole("button", { name: tEn("stock:lotsButton") }).first().click();
await expect(page.getByRole("heading", { name: tEn("stock:lotsHeading") })).toBeVisible();

const lotRows = page.getByRole("button", { name: tEn("stock:historyButton"), exact: true });
await expect(lotRows).toHaveCount(50);
const loadMore = page.getByRole("button", { name: tEn("stock:loadMoreButton") });
await expect(loadMore).toBeVisible();

await loadMore.click();
await expect(lotRows).toHaveCount(100);
// ~176 lots in the fixture: still more to load after two pages.
await expect(loadMore).toBeVisible();
});

test("is not offered the destinations it cannot use", async ({ nav }) => {
for (const key of ["nav:customers", "nav:sales", "nav:audit", "nav:users", "nav:expenses"]) {
await expect(
Expand Down
18 changes: 18 additions & 0 deletions tools/simulation/ui/src/mutants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,24 @@ export const MUTANTS: Record<string, Mutant> = {
},

// --- data integrity on screen -------------------------------------------
"stock-pager-inert": {
breaks:
"offset paging on the lot list (#465) — every page request is rewritten to offset 0, so "
+ "load more re-serves page one forever and older lots stay unreachable (the pre-#465 "
+ "behavior; the SPA's id-dedupe silently appends nothing)",
caughtBy: "readonly.spec.ts — pages a deep grade's lots with load more (#465)",
apply: async (page) => {
await page.route("**/api/v1/stock/lots**", async (route) => {
const url = new URL(route.request().url());
const offset = url.searchParams.get("offset");
if (offset === null || offset === "0") return route.fallback();
url.searchParams.set("offset", "0");
const response = await route.fetch({ url: url.toString() });
await route.fulfill({ response });
});
},
},

"stock-summary-broken": {
breaks: "the stock summary fetch, so the dashboard's stat tiles fall back to their em-dash",
caughtBy: "owner.spec.ts — dashboard shows real production, stock and sales data",
Expand Down
6 changes: 5 additions & 1 deletion web/src/api/cluckwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,13 @@ export interface EggMovementRow {
createdAtUtc: string;
}

export const listEggLots = (params?: { gradeId?: string; limit?: number; offset?: number }) => {
export const listEggLots = (params?: {
gradeId?: string; from?: string; to?: string; limit?: number; offset?: number;
}) => {
const q = new URLSearchParams();
if (params?.gradeId) q.set("gradeId", params.gradeId);
if (params?.from) q.set("from", params.from);
if (params?.to) q.set("to", params.to);
if (params?.limit) q.set("limit", String(params.limit));
if (params?.offset) q.set("offset", String(params.offset));
return apiGet<EggLotRow[]>(`/stock/lots${q.size > 0 ? `?${q}` : ""}`);
Expand Down
9 changes: 8 additions & 1 deletion web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,11 @@ export const en = {
producedHeader: "Produced",
historyButton: "history",
hideHistoryButton: "hide history",
// #465 — server-side paging + production-date window over the lots table,
// so old lots stay reachable. Same wording as the History/Audit filters.
fromLabel: "From",
toLabel: "To",
loadMoreButton: "load more",

// Movement ledger drill-down (per lot). Type reads the RAW server value
// through stockMovementLabel — the full EggMovementType enum is covered
Expand Down Expand Up @@ -2263,7 +2268,9 @@ export const en = {
stockLots:
"Every grade expands into its <strong>lots</strong> (one per submitted day), and every lot into its "
+ "<strong>movement ledger</strong> — an explicit line for each production, sale, correction, or void. "
+ "The running sum always equals the balance shown; nothing changes stock without leaving a line.",
+ "The running sum always equals the balance shown; nothing changes stock without leaving a line. "
+ "The lot list shows the newest 50 at a time — narrow it with the <strong>From/To</strong> dates "
+ "to reach an older lot, or keep loading more.",
stockRestricted:
"Stock is the sum of your egg lots per grade. The <strong>restricted</strong> column is reserved for "
+ "medication withholding periods — that feature arrives with medication tracking. <strong>Nothing "
Expand Down
8 changes: 7 additions & 1 deletion web/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,10 @@ export const es = {
producedHeader: "Producido",
historyButton: "historial",
hideHistoryButton: "ocultar historial",
// #465 — paginación + ventana de fechas sobre la tabla de lotes.
fromLabel: "Desde",
toLabel: "Hasta",
loadMoreButton: "cargar más",

// Movement ledger drill-down (per lot)
movementLedgerHeading: "Registro de movimientos",
Expand Down Expand Up @@ -1878,7 +1882,9 @@ export const es = {
"Cada grado se expande en sus <strong>lotes</strong> (uno por día enviado), y cada lote en su "
+ "<strong>libro mayor de movimientos</strong> — una línea explícita por cada producción, venta, "
+ "corrección o anulación. La suma acumulada siempre es igual al saldo mostrado; nada cambia las "
+ "existencias sin dejar una línea.",
+ "existencias sin dejar una línea. La lista de lotes muestra los 50 más recientes a la vez — "
+ "acótela con las fechas <strong>Desde/Hasta</strong> para llegar a un lote antiguo, o siga "
+ "cargando más.",
stockRestricted:
"Las existencias son la suma de sus lotes de huevos por grado. La columna <strong>restringido</strong> "
+ "está reservada para períodos de retiro por medicación — esa función llega con el seguimiento de "
Expand Down
8 changes: 7 additions & 1 deletion web/src/i18n/tl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,10 @@ export const tl = {
producedHeader: "Produksyon",
historyButton: "history",
hideHistoryButton: "itago ang history",
// #465 — paging + saklaw ng petsa sa talahanayan ng mga lote.
fromLabel: "Mula",
toLabel: "Hanggang",
loadMoreButton: "mag-load pa",

// Movement ledger drill-down (per lot)
movementLedgerHeading: "Talaan ng Galaw",
Expand Down Expand Up @@ -1953,7 +1957,9 @@ export const tl = {
"Ang bawat grado ay lumalawak sa <strong>mga lote</strong> nito (isa kada naisumiteng araw), at ang "
+ "bawat lote naman ay lumalawak sa <strong>talaan ng galaw</strong> nito — isang malinaw na linya "
+ "para sa bawat production, benta, pagtatama, o void. Palaging katumbas ng balanseng ipinapakita ang "
+ "tumatakbong kabuuan; walang nagbabago sa stock nang hindi nag-iiwan ng linya.",
+ "tumatakbong kabuuan; walang nagbabago sa stock nang hindi nag-iiwan ng linya. Ipinapakita ng "
+ "listahan ng lote ang pinakabagong 50 sa bawat pagkakataon — paliitin ito gamit ang mga petsang "
+ "<strong>Mula/Hanggang</strong> para maabot ang mas lumang lote, o magpatuloy sa pag-load pa.",
stockRestricted:
"Ang stock ay ang kabuuan ng mga lote ng itlog mo kada grado. Ang column na "
+ "<strong>restricted</strong> ay nakalaan para sa mga withholding period ng gamot — darating ang "
Expand Down
Loading
Loading