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
16 changes: 10 additions & 6 deletions tools/simulation/ui/specs-screenshots/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,16 @@ test.describe("README screenshots", () => {
// name interpolates a flock name this spec does not know.
await expect(page.locator(".capture-tile").first()).toBeVisible();

// Trend: the sparkline is there AND not flat — the fixture seeds 90 days
// of production, so a flat line means the report did not arrive.
const line = page.locator("svg.sparkline");
await expect(line).toBeVisible();
const ys = (await line.locator("polyline").getAttribute("points"))!.split(" ").map((p) => p.split(",")[1]);
expect(new Set(ys).size).toBeGreaterThan(1);
// Trend: the day strip is there AND not flat — the fixture seeds 90 days
// of production, so bars of one height mean the report did not arrive.
// Fourteen slots are drawn whatever the figures (#777), so counting slots
// would pass on a missing report; the BAR heights are the live signal.
const strip = page.locator(".daystrip");
await expect(strip).toBeVisible();
const bars = strip.locator(".day > i");
expect(await bars.count()).toBeGreaterThan(1);
const heights = await bars.evaluateAll((els) => els.map((e) => (e as HTMLElement).style.height));
expect(new Set(heights).size).toBeGreaterThan(1);

// Stock: at least one segment on the bar.
await expect(page.locator(".meter-stack > span").first()).toBeVisible();
Expand Down
55 changes: 55 additions & 0 deletions web/src/components/DayStrip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// web/src/components/DayStrip.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { DayStrip } from "./DayStrip";
import type { DayStripData } from "../lib/dashboard";

const slot = (date: string, value: number, heightPct: number, weekBreak = false) =>
({ date, heightPct, recorded: value > 0, weekBreak });

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,
};

describe("DayStrip (#654, #777)", () => {
it("draws a slot for every day and a bar only for a day with a figure", () => {
const { container } = render(
<DayStrip data={data} label="Eggs per day" title="Eggs per day" peak="Peak 10" from="1 Jul" to="3 Jul" />,
);
// 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 bars = Array.from(container.querySelectorAll(".day > i")).map((b) => (b as HTMLElement).style.height);
expect(bars).toEqual(["100%", "50%"]);
});

it("marks the week boundary on exactly the slot that carries it", () => {
const { container } = render(
<DayStrip data={data} label="Eggs per day" title="Eggs per day" peak="Peak 10" from="1 Jul" to="3 Jul" />,
);
const marked = Array.from(container.querySelectorAll(".daystrip > .day"))
.map((d) => d.classList.contains("day-week"));
expect(marked).toEqual([false, false, true]);
});

it("names the picture, the scale and both ends of the window, and follows a re-render", () => {
const { rerender, container } = render(
<DayStrip data={data} label="Eggs per day" title="Eggs per day" peak="Peak 10" from="1 Jul" to="3 Jul" />,
);
expect(screen.getByRole("img", { name: "Eggs per day" })).toBeInTheDocument();
expect(screen.getByText("Peak 10")).toBeInTheDocument();
expect(screen.getByText("1 Jul")).toBeInTheDocument();
expect(screen.getByText("3 Jul")).toBeInTheDocument();

rerender(
<DayStrip
data={{ slots: [slot("2026-08-01", 0, 0)], min: 0, max: 0, last: 0 }}
label="Flat" title="Eggs per day" peak="Peak 0" from="1 Aug" to="1 Aug"
/>,
);
expect(screen.getByRole("img", { name: "Flat" })).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "Eggs per day" })).not.toBeInTheDocument();
expect(container.querySelectorAll(".day > i")).toHaveLength(0);
});
});
45 changes: 45 additions & 0 deletions web/src/components/DayStrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// web/src/components/DayStrip.tsx
import type { DayStripData } 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").
//
// 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).
//
// `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 }: {
data: DayStripData;
label: string;
title: string;
peak: string;
from: React.ReactNode;
to: React.ReactNode;
}) {
return (
<figure className="trend">
<figcaption className="trend-scale">
<span>{title}</span>
<span className="trend-peak">{peak}</span>
</figcaption>
<div className="daystrip" role="img" aria-label={label}>
{data.slots.map((s) => (
<span key={s.date} className={s.weekBreak ? "day day-week" : "day"}>
{s.recorded && <i style={{ height: `${s.heightPct}%` }} />}
</span>
))}
</div>
<div className="trend-rule">
<span>{from}</span>
<span>{to}</span>
</div>
</figure>
);
}
19 changes: 0 additions & 19 deletions web/src/components/Sparkline.test.tsx

This file was deleted.

14 changes: 0 additions & 14 deletions web/src/components/Sparkline.tsx

This file was deleted.

18 changes: 9 additions & 9 deletions web/src/components/StockBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,26 @@ import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { StockBar } from "./StockBar";

const widths = (root: HTMLElement) =>
Array.from(root.querySelectorAll(".meter-stack > span")).map((s) => [(s as HTMLElement).style.width, (s as HTMLElement).style.opacity]);
const bands = (root: HTMLElement) =>
Array.from(root.querySelectorAll(".meter-stack > span")).map((s) => [(s as HTMLElement).style.width, s.className]);

describe("StockBar (#654)", () => {
it("renders one segment per grade with its exact width and opacity, hidden from the a11y tree, and follows a re-render", () => {
describe("StockBar (#654, #777)", () => {
it("renders one segment per grade with its exact width and grade hue, hidden from the a11y tree, and follows a re-render", () => {
const { container, rerender } = render(<StockBar data={{
segments: [
{ eggGradeId: "g1", gradeName: "Large", available: 1240, pct: 79.5, opacity: 1 },
{ eggGradeId: "g2", gradeName: "Medium", available: 320, pct: 20.5, opacity: 0.87 },
{ eggGradeId: "g1", gradeName: "Large", available: 1240, pct: 79.5, colorIndex: 1 },
{ eggGradeId: "g2", gradeName: "Medium", available: 320, pct: 20.5, colorIndex: 2 },
],
totalAvailable: 1560, totalRestricted: 0,
}} />);
expect(container.querySelector(".meter-stack")).toHaveAttribute("aria-hidden", "true");
expect(widths(container)).toEqual([["79.5%", "1"], ["20.5%", "0.87"]]);
expect(bands(container)).toEqual([["79.5%", "grade-1"], ["20.5%", "grade-2"]]);

rerender(<StockBar data={{
segments: [{ eggGradeId: "g9", gradeName: "Jumbo", available: 5, pct: 100, opacity: 1 }],
segments: [{ eggGradeId: "g9", gradeName: "Jumbo", available: 5, pct: 100, colorIndex: 3 }],
totalAvailable: 5, totalRestricted: 0,
}} />);
expect(widths(container)).toEqual([["100%", "1"]]);
expect(bands(container)).toEqual([["100%", "grade-3"]]);
});
it("renders an empty track when there are no segments", () => {
const { container } = render(<StockBar data={{ segments: [], totalAvailable: 0, totalRestricted: 0 }} />);
Expand Down
9 changes: 6 additions & 3 deletions web/src/components/StockBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
import type { StockBarData } from "../lib/dashboard";

// #654 — the stock stacked bar: one span per grade on the `.meter-stack`
// track, widths and opacities precomputed (lib/dashboard.ts stockBar).
// Decorative: the screen's caption is the text of record, so the track is
// track, widths precomputed (lib/dashboard.ts stockBar). Decorative: the
// legend the screen renders beside it is the text of record, so the track is
// hidden from the accessibility tree like StockPage's `.meter`.
//
// #777 — grade is carried by a categorical hue (`grade-N`, see GRADE_COLOURS),
// not by an opacity ramp that ran out at the sixth grade.
export function StockBar({ data }: { data: StockBarData }) {
return (
<div className="meter-stack" aria-hidden="true">
{data.segments.map((s) => (
<span key={s.eggGradeId} style={{ width: `${s.pct}%`, opacity: s.opacity }} />
<span key={s.eggGradeId} className={`grade-${s.colorIndex}`} style={{ width: `${s.pct}%` }} />
))}
</div>
);
Expand Down
28 changes: 19 additions & 9 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,21 +775,31 @@ export const en = {
moreFlocks_one: "{{total}} more flock",
moreFlocks_other: "{{total}} more flocks",

// Last 14 days (#654): the production report's own figures — submitted
// days only, fixed window (yesterday back), hen-day % is the server's
// period figure for the last 7 complete days vs the 7 before.
// 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.
trendPanelTitle: "Last 14 days",
sparklineLabel: "Eggs per day, last 14 days: lowest {{min}}, highest {{max}}, yesterday {{last}}",
henDayCaption: "Hen-day {{pct}} · {{delta}} vs the previous 7 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.",
trendScaleTitle: "Eggs per day",
trendPeak: "Peak {{total}}",
henDaySubLabel: "Hen-day, last 7 days against the 7 before",
henDayDeltaUp: "+{{delta}} pts",
henDayDeltaDown: "−{{delta}} pts",

// Stock (#654): one stacked bar by grade; the caption is the text of record.
// Stock (#654, #777): one stacked bar by grade, with the ledger beside it
// as the text of record (the track itself is hidden from assistive tech).
stockPanelTitle: "Stock",
noStockMessage: "No stock yet — record and submit a daily entry.",
// {{total}} is the farm-locale formatted figure (#650); {{count}} stays for plural selection.
eggsAvailableMessage_one: "{{total}} egg available.",
eggsAvailableMessage_other: "{{total}} eggs available.",
// The figure is rendered beside this label, so the label carries no
// placeholder; {{count}} stays only for plural selection (#650).
eggsAvailableLabel_one: "egg available",
eggsAvailableLabel_other: "eggs available",
stockLedgerLabel: "Stock by grade",
stockCaptionRestricted: "{{restricted}} restricted",

// "Recent sales" panel (hidden for ReadOnly/Denied, #127)
Expand Down
13 changes: 9 additions & 4 deletions web/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,16 +595,21 @@ export const es = {

// Últimos 14 días (#654)
trendPanelTitle: "Últimos 14 días",
sparklineLabel: "Huevos por día, últimos 14 días: mínimo {{min}}, máximo {{max}}, ayer {{last}}",
henDayCaption: "Postura {{pct}} · {{delta}} frente a los 7 días anteriores",
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.",
trendScaleTitle: "Huevos por día",
trendPeak: "Máximo {{total}}",
henDaySubLabel: "Postura, últimos 7 días frente a los 7 anteriores",
henDayDeltaUp: "+{{delta}} pts",
henDayDeltaDown: "−{{delta}} pts",

// Existencias (#654)
stockPanelTitle: "Existencias",
noStockMessage: "Aún no hay existencias — registre y envíe un registro diario.",
eggsAvailableMessage_one: "{{total}} huevo disponible.",
eggsAvailableMessage_other: "{{total}} huevos disponibles.",
eggsAvailableLabel_one: "huevo disponible",
eggsAvailableLabel_other: "huevos disponibles",
stockLedgerLabel: "Existencias por grado",
stockCaptionRestricted: "{{restricted}} restringidos",

// Ventas recientes (oculto para Solo lectura/Denegado, #127)
Expand Down
13 changes: 9 additions & 4 deletions web/src/i18n/tl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,8 +620,12 @@ export const tl = {

// Huling 14 araw (#654)
trendPanelTitle: "Huling 14 araw",
sparklineLabel: "Itlog bawat araw, huling 14 araw: pinakamababa {{min}}, pinakamataas {{max}}, kahapon {{last}}",
henDayCaption: "Hen-day {{pct}} · {{delta}} kumpara sa nakaraang 7 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.",
trendScaleTitle: "Itlog bawat araw",
trendPeak: "Pinakamataas {{total}}",
henDaySubLabel: "Hen-day, huling 7 araw laban sa naunang 7",
henDayDeltaUp: "+{{delta}} pts",
henDayDeltaDown: "−{{delta}} pts",

Expand All @@ -631,8 +635,9 @@ export const tl = {
// Tagalog does not inflect the noun for number here, so both forms are the
// same sentence; both keys still exist because the catalogs must carry an
// identical key set (catalogParity.test.ts).
eggsAvailableMessage_one: "{{total}} itlog ang available.",
eggsAvailableMessage_other: "{{total}} itlog ang available.",
eggsAvailableLabel_one: "itlog ang available",
eggsAvailableLabel_other: "itlog ang available",
stockLedgerLabel: "Stock ayon sa grado",
stockCaptionRestricted: "{{restricted}} restricted",

// Mga kamakailang benta (nakatago para sa ReadOnly/Denied, #127)
Expand Down
Loading
Loading