Repository navigation
Dashboard: Recent sales rows do not form columns, and the trend and stock charts are hard to read #777
Description
Activity
- addedbugSomething isn't workingSomething isn't workingarea:frontendReact/Vite web clientReact/Vite web clientseverity:p3Defect: degraded or partial behaviourDefect: degraded or partial behavioursize:SHours to a day; few files, no migrationHours to a day; few files, no migration
on Sep 12, 2026 - changed the title
[-]Dashboard: Recent sales rows do not line up into columns, and reference numbers wrap mid-token[/-][+]Dashboard: Recent sales rows do not form columns, and the trend and stock charts are hard to read[/+]on Sep 12, 2026 Mockups for review
Static mockups, not the running app. They use the real token values from
styles.cssfor both themes, so the colours and hairlines are the ones that would ship. Captured at 1:1, no downscaling. The sales data is from the two screenshots that filed this; the 14-day series mirrors their shape (nine recorded days, then the cliff to zero).Decision A is settled, and it changes the scope
ReportQueries.cs:157walksfor (var d = from; d <= to; d = d.AddDays(1))and emits one row per calendar day, withtotal = row?.Total ?? 0. So:- Point 1 is not a live defect. The series is dense by construction; index position and date position agree today. I would still derive x from the day rather than the index, so the picture cannot go wrong silently if that ever changes, and pin it with a unit test on a deliberately short series. Cheap, and it removes the assumption.
- Point 2 needs a server change and is out of scope here.
ProductionDaycarries no signal that separates "nobody entered" from "zero eggs" —TotalEggs,FromCountsandDeathsall default to0, andHenDayPctis about birds, not entries. It cannot be distinguished client-side. Per this issue's own instruction I will file a follow-up for an entry-presence field onProductionDayrather than grow this one. The five flat days in the mockup are still asserted as zero-production days. That is the one thing these mockups do not fix.
Decisions taken
1 — what makes the columns. The tracks are sized on the
<ul>and each<li>is agrid-template-columns: subgridrow of it, so the columns align down the list instead of per row. Tracks aremax-content | minmax(0,1fr) | max-content | max-content. The<li>keeps its own box, so the hairline and thearia-labelare untouched — whichdisplay: contentswould have cost.2 — the badge column is not sized from English.
max-contentmeasures whatever the farm's locale renders. The fourth sales panel showstl, whose status vocabulary is the longest; the column just gets wider. Nothing is in pixels and nothing is derived from a screenshot.3 — the customer name truncates, it does not wrap. Rows stay one line, so the four columns scan. The clip is visual only: the link's accessible name is still the whole name, and following it lands on Sales filtered to that customer. This is the decision I am least sure of and the one I would most like your call on — at a 470px panel the names truncate to
Sim Custom…, which is visible in the mockup. The alternatives are to drop the reference number to a second line, or to give the name track priority over the badge.4 — the reference number gets
white-space: nowrap. Stated, not inherited from the browser.5 — the narrow case is a container query, not a viewport query. The first mockup pass used
@media (max-width: 34rem)and it was wrong: a 330px panel on a 1500px viewport still got four columns and the name column collapsed to nothing. The panel's own width is what decides, so it is@containerbelow 26rem, and the row becomes reference + amount, then customer + status.On #674: that epic is still at its design-doc step, so this lands ahead of it rather than waiting.
B — the stock bar becomes categorical, not a wider opacity ramp. Eight declared hues per theme, deliberately independent of the farm's brand palette (the brand identifies the farm; these identify grades, and letting the palette move them would make the same grade a different colour on two deployments). Past eight the hues repeat and the legend is what disambiguates — the second row of stock mockups shows eight grades under both encodings. The bar is also 12px rather than 8px, and a segment has a 3px floor so Dirty at 0.6% is visible; the width is otherwise the true share.
C — the sparkline stays a shape-at-a-glance chart. It gains the zero baseline, both ends of the scale named (
Eggs per day/Peak 1,310above,0below the baseline), a dot on the latest day, and 5.5rem instead of 3.25rem. No axis and no hover readout: reading it for values is what Reports is for, and the panel heading already links there. This also fixes the panel showing one quantity and describing another — the chart now says it plots eggs per day, beside a caption about hen-day.D — legend, not in-place labels. Costs two lines in the panel and removes the
62,672 eggs available. · Small 9,406 · Medium 18,508 · …run-on entirely, which was asking the reader to count segments and trust the caption order matched.E — the accessible text of record is replaced, not removed. The track stays
aria-hidden="true"; the legend is now what carries name and figure per grade, so the caption can shed the list.F — no chart library. Nothing here needs one.
Not in scope, and why
Restricted stock still does not reach the bar (point 10). The bar means available, which is what the caption and the Stock page both mean; putting restricted in the track changes what the panel shows, and this issue says presentational only. Worth its own decision.
Nothing user-visible changes as a concept, so the glossary and the Help page are untouched.
Implementation is drafted and parked on
fix/777-dashboard-panelspending this review.Charts, second pass
Same harness as the first comment: real
styles.csstoken values, both themes, 1:1. This supersedes the chart half of the previous mockups; the Recent sales half of that comment is unchanged.The line becomes a day strip
Eggs per day is a discrete daily count, and the thing this panel is asked in the morning is whether production held. Fourteen slots, one per day, drawn whether or not the day has a figure.
Correction (added after an adversarial review of the implementation). The sentence that followed here claimed a line "drawn through five unrecorded days renders them as a plateau". That is wrong.
sparklinemapped a zero day toy = SPARK_H, the floor of its viewBox, so a zero was drawn as a drop, which is what the screenshots show. The strip is still the better form, for the reasons restated below, but not for the reason originally given.That form is also the honest one, which is why it wins rather than just looking better. The line never drew either end of its 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. As fourteen slots with both ends of the scale named, neither is true. The slot track is drawn at 7% of the accent so a day with nothing in it is a place where nothing happened, not an absence of chart. It does not separate a zero day from an unrecorded one; that is #780.
The week boundary is a single hairline between day 7 and day 8, because the caption underneath compares exactly those two weeks. My first version instead tinted the two weeks differently, and it was wrong in both themes: in this data the current week is mostly empty, so the chart faded the only bars that carried a figure. One divider, one accent colour.
Hen-day is now a figure rather than a clause in a muted sentence. It is the panel's actual measure and it was the smallest text in the panel. The delta keeps the existing
−4.7 ptsstring and takes the semantic colour.What I deliberately did not add
No average reference line. It was the obvious next mark and I cut it. Averaging the recorded days asserts the five flat days are absences; averaging all fourteen asserts they are real zeros. Both are the unresolved question from decision A, and a reference line that quietly picks a side is worse than no line. It becomes available once
ProductionDaycan say whether a day was entered.The bars stay anchored to zero, so a steady farm produces fourteen near-equal bars. That is the truth about a steady farm, and shortening the axis to dramatise a 3% swing would be a lie about the ratios. The variation question is answered by the hen-day delta, which is why it got the size it did.
Stock
The total leads now. It is the whole that the strip divides, and it was sitting under the legend as the least prominent thing in the panel.
The ledger carries a share alongside the count, so Dirty at 0.6% is readable as a number even though its band is a sliver. Row rules are gone; alignment does that work.
The grade hues were reordered after looking at the render. The first pass ran brick, ochre, green at positions 1, 2 and 3, so a farm with Small, Medium, Large read as a red/amber/green ramp — the interface saying Small is bad. The wheel now opens blue, violet, green. Still eight evenly spaced hues at even lightness per theme, still independent of the farm's brand palette, still repeating past eight with the ledger doing the naming.
Where I spent the risk
One place: the day strip. Everything around it is quiet on purpose. Nothing new is animated, no new typeface, no new radius, and both panels stay inside the existing token system, so the four farm palettes and both themes carry through unchanged.
- added a commit that references this issue
on Sep 12, 2026




What
The Dashboard's Recent sales panel renders four fields per row (reference number, customer, status badge, amount) but only the amount is positioned. The other three take their intrinsic width, so nothing lines up: the status badge sits at a different horizontal position in every row, and the reference number and customer name wrap onto two or three lines at widths where they do not need to.
Reported from two live screenshots, one per theme and viewport. Both show the same shape. In one,
SO-CD2B6471breaks after the hyphen onto two lines whileSO-1318252Cstays on one, andSim Customer Filler 001wraps to three lines and pushes its badge and amount down. In the other, the badge afterJRsits roughly 90px to the left of the badge afterTita Egg Tetailing. The amounts are the only column that reads as a column, and that is not a coincidence — see below.Screenshots to attach. They were pasted into the session that filed this and are not on disk. Whoever picks this up should capture fresh before/after images per
AGENTS.md, which requires them on the PR anyway.Root cause, read from the repo
web/src/routes/Dashboard.tsx:212-222renders each row as four siblings inside one<li>:web/src/styles.css:1819lays that out as:So it is a flex row of intrinsically-sized items with exactly one item pinned.
margin-left: autoon.numis why the amounts align and why nothing else does. This is a layout that was never asked to form columns, and at three of four columns it does not.The mid-token wrap is a second, independent defect in the same rows.
SO-CD2B6471is a single identifier and breaking it after the hyphen makes it harder to read and match against the Sales page, on top of making the row taller than its neighbours.Call-site count, per
AGENTS.mdgrep -rn "dash-list" web/src --include='*.tsx'returns exactly one call site,web/src/routes/Dashboard.tsx:212. So the blast radius of restyling.dash-listis this panel and nothing else. Recording it here because the rule says to record it before styling, and because a zero-or-one count is the case where people assume otherwise.Decide before writing code
1 — what makes the columns. A CSS grid with four explicit tracks is the obvious answer and probably the right one, but the track sizing is the whole decision.
autoper track reproduces the current problem inside a grid. Fixed pixel tracks break under i18n, see below.minmax()with the flexible track on the customer name is the likely shape; decide it deliberately rather than discovering it.2 — the badge column must not be sized from English. The status vocabulary is translated (
web/src/i18n/enums.ts→enums:status.*). The longest sales status label is 9 characters inen(Confirmed,Cancelled) and 10 intl(Kumpirmado,Na-invoice); across the whole status settlreaches 12 (Hindi Aktibo,Naka-archive). A column measured against the English screenshot is roughly 30% too narrow fortl, which is the locale a layout defect has already landed in once (#688). Check all three locales, not just the one on your screen.3 — wrap or truncate the customer name.
Sim Customer Filler 001currently wraps to three lines. Truncating with an ellipsis keeps rows uniform but hides the end of a name, and the name is a link into filtered Sales, so the user is choosing a target from it. Pick one and say why; do not leave it to whatever the track sizing happens to do.4 — keep the reference number on one line. Something like
white-space: nowrapon that cell, or an explicitoverflow-wraprule. Whatever it is, it should be a stated choice, because the current behaviour is the browser's default and not a decision anyone made.5 — narrow viewports. The first screenshot is already narrow enough that the reference number wraps. A four-track grid at phone width may want to become two rows rather than four squeezed columns. #674 is reworking the SPA on a field-first phone direction, so check whether this should wait on that or land ahead of it.
Scope
Presentational only. No API change, no change to what the panel shows or who can see it. The
canSeeSalesgate (#127), thearia-labelon the row, and the customer link'scustomerIdtarget (#512 US5) all stay exactly as they are.Repo rules this touches
AGENTS.md· Operations and callers — a PR that changes what a user sees attaches before and after screenshots at 1:1, from a stack rebuilt at the head under review. This issue is the case that rule exists for; the defect is only visible in the render.AGENTS.md· Writing a guard, AGENTS.md: three conventions earned while shipping #651 + #652 #662 — the call-site count above is recorded because the rule requires it before styling a selector.tlsizing trap in decision 2. Nothing enforces locale-aware sizing; only review catches it.Part 2 — the two charts on the same Dashboard are hard to read
Added to this issue rather than split out, because it is the same panel row, the same stylesheet region, and the same before/after screenshot pass. The "Last 14 days" sparkline and the "Stock" stacked bar both landed in #654 as deliberately minimal inline SVG/CSS with no chart library, which was the right call then. They have since become the two things on the Dashboard that carry the most information and communicate the least of it.
What is wrong, read from the repo
Last 14 days (
web/src/components/Sparkline.tsx,web/src/lib/dashboard.ts:sparkline,web/src/styles.css:1777)1. The x axis is index-based, not date-based.
sparkline()positions each point ati * stepwherestep = SPARK_W / (values.length - 1). Nothing consults the day's date. If the production report ever omits a day rather than returning it with zero eggs, the line silently closes the gap and every point after it is plotted on the wrong day, with no visual cue that it happened. Whether the API can return a short series is the first thing to establish; if it can, this is a correctness defect wearing a styling defect's clothes.2. A day with no entry looks identical to a day that produced zero eggs. The screenshot shows the line cliff to a flat floor for the last stretch of the window. Those are almost certainly days nobody has entered yet, and the chart asserts they were zero-production days.
entryForin the same file already knows "no entry yet" is a distinct state and the capture tiles render it as an alarm; the sparkline flattens the distinction away.3. The y axis is scaled to the series maximum with no reference of any kind.
SPARK_H - (v / max) * SPARK_Hmeans the top of the box is always the best day in the window and the bottom is always zero, but neither is drawn, labelled, or otherwise knowable. A 3% swing and a 60% swing produce the same picture. In the screenshot the ten real days are compressed into a thin band near the top because one excursion to zero owns the whole vertical range, so the actual day-to-day variation — the thing the panel exists to show — is invisible.4. There is no endpoint marker, no baseline, no hover or focus readout. The only place any number lives is the
aria-label, which a sighted user never sees. The caption below carries hen-day percent, which is a different measure from the eggs-per-day the line plots, so the panel shows one quantity and describes another.5. The aspect ratio crushes the signal. 14 points across the full panel width at
height: 3.25remis roughly 11:1. Nothing about that is load-bearing; it was a default.Stock (
web/src/components/StockBar.tsx,web/src/lib/dashboard.ts:stockBar,web/src/styles.css:1792)6. Grades are encoded by opacity alone.
opacity: max(0.35, 1 - 0.13 * i), one accent colour for every segment. Five grades give 1.0 / 0.87 / 0.74 / 0.61 / 0.48, which in the screenshot read as one continuous bar with three hairline gaps in it. Opacity is a weak channel for a categorical dimension at the best of times, and it is weakest exactly here, against a dark surface at 8px tall. The code comment says the steps keep grades "distinguishable in every palette"; the render says otherwise, and that gap between the stated intent and the result is the reason this needs the screenshot check rather than another read of the CSS.7. Past six grades the encoding stops encoding. The 0.35 floor is reached at
i = 5, so a farm with seven grades gets three segments that are literally the same colour. Egg grades are user-editable (#283 guards them whole-set precisely because farms rename and add them), so six is not a safe ceiling.8. There is no legend, and the caption is a list rendered as prose.
62,672 eggs available. · Small 9,406 · Medium 18,508 · Large 33,710 · Cracked 668 · Dirty 380wraps to two lines and puts the reader in the position of counting segments left to right and trusting that the caption order matches. Nothing on screen connects a name to a band.9. Small segments are unreadable. Dirty at 380 of 62,672 is 0.6%, roughly 3px on a full-width bar, and by index it also carries one of the lowest opacities. It is present, and it cannot be seen or hovered.
10. Restricted stock never reaches the bar.
stockBarcomputestotalRestrictedand only the caption uses it. Whether restricted volume belongs in the track is a product question, but right now the bar silently claims to show the stock and shows only part of it.Decide before writing code
A — establish 1 and 2 first, because they may not be styling at all. Read what the production report returns for a day with no entry, and for a date range extending past the last entry. If either produces a short series or a synthetic zero, fix the chart's honesty before anyone touches its appearance. A prettier chart that asserts a wrong number is strictly worse than the current one.
B — pick the encoding for the stock bar deliberately. The real choice is whether grade stays a single-hue ramp or becomes a categorical palette. Do not simply widen the opacity steps; that keeps a weak channel and only defers the point where it fails. Whatever is chosen has to survive both themes, the non-default farm palettes (#664 exists because those are a real surface), and a farm with more grades than the designer had in mind. State the maximum grade count the encoding is good for, and what happens past it.
C — decide what a sparkline is for here. If it is a shape-at-a-glance, it needs a zero reference and an endpoint dot and little else. If it is meant to be read for values, it needs an axis and a hover readout and is no longer a sparkline. Pick one; the current thing is halfway and serves neither.
D — legend or labelled segments for stock. A legend costs vertical space in a panel that has little. Labelling the two or three largest segments in place may be better. This is a genuine tradeoff and worth a quick side-by-side rather than an opinion.
E — do not remove
aria-hidden="true"from the stock track without replacing the text of record. It is hidden deliberately, because the caption is the accessible content. If the bar gains a legend or in-place labels, the accessibility story changes and has to be re-decided, not inherited.F — no chart library. #654 chose inline SVG and CSS on purpose. Nothing here needs a dependency, and adding one to make a 14-point line prettier would be a poor trade against bundle size. If someone believes otherwise, that is its own issue with its own argument.
What this part does NOT include
No change to what either panel measures, no new API field, and no change to the hen-day caption's arithmetic (
henDayTrendreads two server figures and subtracts them, per thelib/dashboard.tsheader note that pages never re-derive report rows). If fixing 1 or 2 turns out to need a server change, that is a separate issue and this one should say so rather than grow one.