Skip to content

Dashboard: Recent sales rows do not form columns, and the trend and stock charts are hard to read #777

Description

@mforce

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-CD2B6471 breaks after the hyphen onto two lines while SO-1318252C stays on one, and Sim Customer Filler 001 wraps to three lines and pushes its badge and amount down. In the other, the badge after JR sits roughly 90px to the left of the badge after Tita 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-222 renders each row as four siblings inside one <li>:

<li key={o.id} aria-label={o.referenceNumber}>
  <span>{o.referenceNumber}</span>
  <Link className="link" to={`/sales?customerId=${o.customerId}`}>{rowCustomerName(o)}</Link>
  <StatusBadge status={o.status} label={statusLabel(o.status)} />
  <span className="num">{fmt.money(...)}</span>
</li>

web/src/styles.css:1819 lays that out as:

.dash-list li { display: flex; gap: 0.75rem; align-items: center; ... }
.dash-list .num { margin-left: auto; font-variant-numeric: tabular-nums; }

So it is a flex row of intrinsically-sized items with exactly one item pinned. margin-left: auto on .num is 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-CD2B6471 is 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.md

grep -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-list is 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. auto per 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 in en (Confirmed, Cancelled) and 10 in tl (Kumpirmado, Na-invoice); across the whole status set tl reaches 12 (Hindi Aktibo, Naka-archive). A column measured against the English screenshot is roughly 30% too narrow for tl, 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 001 currently 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: nowrap on that cell, or an explicit overflow-wrap rule. 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 canSeeSales gate (#127), the aria-label on the row, and the customer link's customerId target (#512 US5) all stay exactly as they are.

Repo rules this touches


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 at i * step where step = 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. entryFor in 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_H means 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.25rem is 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 380 wraps 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. stockBar computes totalRestricted and 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 (henDayTrend reads two server figures and subtracts them, per the lib/dashboard.ts header 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.

Activity

  1. added
    bugSomething isn't working
    severity:p3Defect: degraded or partial behaviour
    size:SHours to a day; few files, no migration
    on Sep 12, 2026
  2. 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
  3. mforce commented on Sep 12, 2026

    @mforce
    OwnerAuthor

    Mockups for review

    Static mockups, not the running app. They use the real token values from styles.css for 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).

    Light theme mockup

    Dark theme mockup

    Decision A is settled, and it changes the scope

    ReportQueries.cs:157 walks for (var d = from; d <= to; d = d.AddDays(1)) and emits one row per calendar day, with total = 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. ProductionDay carries no signal that separates "nobody entered" from "zero eggs" — TotalEggs, FromCounts and Deaths all default to 0, and HenDayPct is 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 on ProductionDay rather 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 a grid-template-columns: subgrid row of it, so the columns align down the list instead of per row. Tracks are max-content | minmax(0,1fr) | max-content | max-content. The <li> keeps its own box, so the hairline and the aria-label are untouched — which display: contents would have cost.

    2 — the badge column is not sized from English. max-content measures whatever the farm's locale renders. The fourth sales panel shows tl, 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 @container below 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,310 above, 0 below 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-panels pending this review.

  4. mforce commented on Sep 12, 2026

    @mforce
    OwnerAuthor

    Charts, second pass

    Same harness as the first comment: real styles.css token values, both themes, 1:1. This supersedes the chart half of the previous mockups; the Recent sales half of that comment is unchanged.

    Light theme, charts second pass

    Dark theme, charts second pass

    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. sparkline mapped a zero day to y = 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 pts string 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 ProductionDay can 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.

  5. added a commit that references this issue on Sep 12, 2026
    7193ebe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:frontendReact/Vite web clientbugSomething isn't workingseverity:p3Defect: degraded or partial behavioursize:SHours to a day; few files, no migration

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions