Skip to content

fix(sales): keep a line's discount markers agreeing while its price is edited (#752) - #753

Merged
mforce merged 1 commit into
mainfrom
fix/752-discount-edge-cases
Sep 10, 2026
Merged

mforce merged 1 commit into
mainfrom
fix/752-discount-edge-cases

Conversation

@mforce

@mforce mforce commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Closes #752.

Fixes three of the five findings deferred from PR #741. The other two are product decisions, recorded below and on the issue.

Findings 1 and 2 were one defect

The issue lists them separately. They share a root cause, so they got one fix.

A row's tint (tr.discounted) and its Below list chip read the saved line, while the Discount cell rendered a hardcoded "—" for the whole edit. One row, two sources of truth. Two consequences:

  • A below-list line under edit said "discounted" in two places and "not measurable" in a third, simultaneously.
  • An above-list line, whose only marker lives in that cell, lost its marker entirely for as long as the edit was open. That is the state most likely to be misread, because the user is mid-change.

The row now resolves one discount, and the tint, the chip and the cell all read it. While that row is being edited it describes the typed price, following the #445 precedent in this same file where the eggs column tracks the edited quantity instead of going blank. An unparseable price box (mid-keystroke, empty) falls back to the saved line rather than flickering to No list price.

The cell markup is now one discountCell value rendered by both the editing and non-editing branches. Two near-identical copies drifting apart is precisely what produced this bug, so collapsing them is part of the fix rather than tidying.

Finding 5

A percent above 0 but below 0.05 rendered 0.0% beside a non-zero amount. New discountPercent() helper renders <0.1% below the rendering threshold, built from fmt.count(0.1, 1) rather than a string literal so the decimal separator stays the locale's, since es writes 0,1. Applied at every percent render site, the live typing hints included, because the flaw was identical at each and fixing one would have left the others.

No new i18n keys. The form substitutes into the existing {{percent}} placeholder, so en/es/tl, the Help page and GLOSSARY.md are unchanged. Nothing user-visible gained new wording.

Deliberately not fixed

  • Finding 3, an all-above-list order rendering an em dash. Needs new wording in three locales plus Help and glossary sync. That is a product decision about what such an order should say, not a defect.
  • Finding 4, no hover state on a discounted row. The issue itself records it as possibly correct, and a new rule also touches the stylesheet equality-set guards. It deserves a decision, not a reflex.

Both are recorded as decisions on #752 rather than left silent.

Evidence

Three new tests. Reverting SalesPage.tsx alone turns 2 of the 3 RED:

× keeps the tint, the chip and the Discount cell agreeing as the price is edited
× says <0.1% rather than 0.0% when a real discount rounds below the rendered precision
  Tests  2 failed | 1 passed

Stated plainly: the third test passes against the original code too. It guards the new live-discount fallback; it does not catch the original bug. Three new tests is not three bugs caught.

Gates and the #662 before/after follow in a comment once the sim stack is rebuilt at this head.

…s edited (#752)

A row's tint and its Below list chip read the SAVED line, while the Discount
cell rendered a hardcoded em dash for the whole edit. One row, two sources of
truth. The row said "discounted" in two places and "not measurable" in a third
at the same time, and an above-list line, whose only marker lives in that cell,
lost its marker entirely for as long as the edit was open.

The row now resolves ONE discount and the tint, the chip and the cell all read
it. While that row is being edited it describes the TYPED price, following the
#445 precedent in this same file where the eggs column tracks the edited
quantity instead of going blank. An unparseable price box falls back to the
saved line rather than flickering to "no list price".

The cell markup is now one value rendered by both the editing and non-editing
branches. Two near-identical copies drifting apart is what produced this.

Also: a percent above 0 but below 0.05 rendered "0.0%" beside a non-zero
amount, a pair that contradicts itself. It now renders "<0.1%", built from
fmt.count(0.1, 1) rather than a literal so the decimal separator stays the
locale's, since es writes 0,1. Applied at every percent render site, the live
typing hints included, because the flaw was identical at each.

No new i18n keys: the form substitutes into the existing {{percent}}
placeholder, so en/es/tl, the Help page and the glossary are unchanged.

Findings 3 and 4 on the issue are deliberately not addressed here. An
all-above-list order needing its own wording is a product decision with
three-locale and doc-sync cost; the missing hover state on a discounted row the
issue itself records as possibly correct. Both are recorded on #752.
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 9b905350-bff6-4b15-af04-977c199b3b9e


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mforce

mforce commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Gates at ecd986a, run in an isolated worktree

Run from ~/.cluckwork-slices/752/worktree rather than the primary checkout, because another session is working on its own branch in that shared directory and switched it mid-run. Isolating removed the contention.

=== TYPECHECK ===   tsc -b --noEmit          exit 0
=== BUILD ===       precache 66 entries (1264.08 KiB), dist/sw.js generated
=== FULL SUITE ===  Test Files  122 passed (122)
                    Tests  2783 passed (2783)      63.63s

The red proof, verbatim

Reverting web/src/routes/SalesPage.tsx alone, leaving the new tests in place:

 × keeps the tint, the chip and the Discount cell agreeing as the price is edited   344ms
 × says <0.1% rather than 0.0% when a real discount rounds below the rendered precision   102ms
   Tests  2 failed | 1 passed | 187 skipped (190)

The third test, falls back to the saved line rather than flickering when the price box is unparseable, passes on the original code too. It guards the new live-discount fallback rather than catching the original defect. Recorded so nobody reads three new tests as three bugs caught.

#662 evidence is pending, and here is exactly why

This changes what is on screen, so the rule applies. The capture is not done yet: the sim stack on this machine had been up 11 hours, built from #741's head, which is the precise failure #662's "rebuilt at the head under review" clause exists to prevent — it would have served bytes that do not contain this fix while looking entirely plausible.

A rebuild at ecd986a is running now. The before/after will be attached as a comment with gh pr comment --attach, not hosted on a branch.

Worth recording: my first check reported "no sim containers running" and that was wrong. docker ps was failing with a permission error that my shell swallowed, so an empty result read as an empty stack. The shell's group list predated this account's docker group membership; sg docker -c picks it up without a re-login.

@mforce

mforce commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

#662 — 1:1 before/after, both stacks rebuilt from source

Two full sim-stack rebuilds, no reuse: BEFORE at d26d389 (current main, pre-fix), AFTER at ecd986a (this PR's head). Same order, same row, same typed price, same viewport, captured by the same scripted spec against both builds so the only variable is the code.

Captured at 1:1 (1280px viewport, no downscaling), per #662.

Case 1 — a line typed BELOW its list price, mid-edit

Sim Small Eggs, list $0.30, typed 0.20, quantity 24. That is a real 33.3% discount.

BEFORE. The Discount cell reads —. No row tint, no chip. The screen says nothing at all about a line being sold at a third off.

Below-list line mid-edit, before the fix: the Discount cell reads an em dash

AFTER. The row tints, the Below list chip appears beside the product, and the Discount cell reads $2.40 · 33.3%. All three markers agree, live against the typed price. ($0.10 per unit × 24 = $2.40; $0.10 ÷ $0.30 = 33.3%.)

Below-list line mid-edit, after the fix: tint, Below list chip, and $2.40 · 33.3% in the Discount cell

Case 2 — an ABOVE-list line, mid-edit

Same row, typed 1.00 against the $0.30 list. This is finding 2, and it is the more dangerous one: an above-list line's only marker lives in the Discount cell.

BEFORE. —. The line is being sold well over list and carries no marker anywhere on screen for the whole edit.

Above-list line mid-edit, before the fix: the Discount cell reads an em dash and no marker remains

AFTER. The cell reads Above list, the same wording the non-editing row uses, because both branches now render one shared value.

Above-list line mid-edit, after the fix: the Discount cell reads Above list


One honest caveat. The order reference differs between the pairs (SO-A29F9282 vs SO-09F28E02) because reset.sh reseeds the fixture on each rebuild and references are generated per seed. The product, list price, quantity, typed price and row position are identical, which is what the comparison turns on.

Capture method. Scripted, not hand-driven, so it is re-runnable: a spec under tools/simulation/ui/specs-screenshots/ that signs in as the Sales persona through the real login form, opens the first Draft order, and drives the row into the editor. Personas come from the git-ignored cast file and every selector resolves through the SPA's own catalogs, per the suite's standing no-hardcoded-credential and no-hardcoded-English rules. The spec is not committed — it writes to /tmp for this attachment rather than refreshing a committed image, which is the boundary playwright.screenshots.config.ts draws for itself.

Images uploaded with gh pr comment --attach, so they depend on no branch and survive any cleanup.

@mforce
mforce merged commit c159b4b into main Sep 10, 2026
11 checks passed
@mforce
mforce deleted the fix/752-discount-edge-cases branch September 10, 2026 17:42
mforce pushed a commit that referenced this pull request Sep 12, 2026
🤖 I have created a release *beep* *boop*
---


## [0.1.0](v0.0.4...v0.1.0)
(2026-09-12)


### ⚠ BREAKING CHANGES

* log in by farm code, with per-account email identity
([#532](#532)) (#564)

### Features

* **accounts:** add Account.Slug (farm code), suspend/reactivate,
list-accounts verb
([#531](#531))
([3fe9754](3fe9754))
* **accounts:** provision additional farms
([#581](#581))
([006f298](006f298))
* add Aspire local development AppHost
([#567](#567))
([2c9e6b9](2c9e6b9))
* add configurable worker sale allocation
([#619](#619))
([0955095](0955095))
* add searchable entity pickers
([#642](#642))
([60d2053](60d2053))
* **api:** provision-account takes an optional --timezone at creation
([#603](#603))
([#694](#694))
([a0aee39](a0aee39))
* **audit:** show the sales-line audit payload as a readable Details
column ([#745](#745))
([#749](#749))
([d26d389](d26d389))
* **auth:** add ApplicationUser.StepUpLogoutEpoch column
([#338](#338))
([#554](#554))
([18306ee](18306ee))
* certify over-cap simulation fixture bands
([#633](#633))
([a67b2e1](a67b2e1)),
closes [#627](#627)
* **cli:** rename-account verb to change a farm code
([#732](#732))
([#733](#733))
([4b70559](4b70559))
* **customers:** edit existing customer details
([#625](#625))
([#626](#626))
([062a55c](062a55c))
* **jobs:** single-runner leader gate for the durable job worker
([#271](#271))
([#555](#555))
([4148f9b](4148f9b))
* let owners change user email addresses
([#605](#605))
([842347b](842347b))
* log in by farm code, with per-account email identity
([#532](#532))
([#564](#564))
([68adb62](68adb62))
* **ratelimit:** distributed IP-keyed auth limiters
([#544](#544))
([#558](#558))
([ec14972](ec14972))
* **ratelimit:** distributed per-account report concurrency cap with
local-ceiling fallback
([#545](#545))
([#559](#559))
([1522e4e](1522e4e))
* **sales:** mark discounted lines, total the discount, and show it in
the Orders list ([#723](#723),
[#724](#724))
([#741](#741))
([1a07441](1a07441))
* **sales:** record list, old and new price in the order-line audit
payload ([#722](#722))
([#742](#742))
([97c866f](97c866f))
* **sales:** refuse an over-ceiling confirm from a Sales user
([#727](#727))
([#766](#766))
([8c0792a](8c0792a))
* **sales:** show what each order still owes, and filter the list to
unpaid ([#771](#771))
([ca59d68](ca59d68))
* **sales:** snapshot the list price on the order line and show the
discount ([#734](#734))
([cffed5e](cffed5e))
* **sales:** snapshot the product name and unit in the order-line audit
payload ([#747](#747))
([#748](#748))
([0481c06](0481c06))
* scope Worker reads to assigned flocks
([#388](#388))
([#611](#611))
([5884a9a](5884a9a))
* shared-state ports with Redis + in-process fallback
([#543](#543))
([#552](#552))
([f767fa9](f767fa9))
* suspend-account / reactivate-account operator verbs
([#534](#534))
([#573](#573))
([d0be26c](d0be26c))
* **tenancy:** write-side tenant guard + single-assignment TenantContext
([#546](#546))
([#561](#561))
([f371f1d](f371f1d))
* **web:** dashboard rework — capture-status tiles, 14-day trend, stock
as a stacked bar
([#654](#654))
([396ba23](396ba23))
* **web:** date-range filters on audit and expenses, and the stock lot
filter gets its bounded toolbar
([#666](#666),
[#667](#667),
[#653](#653))
([94b188f](94b188f))
* **web:** elevation hierarchy and sentence-case labels
([#651](#651),
[#652](#652))
([#661](#661))
([28db4c7](28db4c7))
* **web:** Expenses and Audit keep a clear-filters control while rows
are still showing
([#679](#679))
([#697](#697))
([b859982](b859982))
* **web:** expenses filters by a date range like its sibling screens
([#667](#667))
([f13858f](f13858f))
* **web:** key the farm brand palette per farm
([#586](#586))
([#600](#600))
([7183a43](7183a43))
* **web:** let operators forget remembered farms
([#598](#598))
([577d94e](577d94e))
* **web:** one-line provenance, bounded date filters, and empty states
that invite action
([#653](#653),
[#655](#655))
([#668](#668))
([80b53f4](80b53f4))
* **web:** prefill the farm code from ?farm= and remember it
([#535](#535))
([#588](#588))
([b7f5cc6](b7f5cc6))
* **web:** split authenticated routes into lazy chunks
([#620](#620))
([5089271](5089271))
* **web:** the audit log filters by a date range, and says which window
is empty ([#666](#666))
([63027e0](63027e0))
* **web:** typeset numbers as numbers and refresh the Help glossary
([#650](#650),
[#657](#657))
([af4fe11](af4fe11))


### Bug fixes

* **api:** order same-instant audit events by a durable monotonic key
([#700](#700))
([8fcf084](8fcf084))
* **api:** print the farm code from bootstrap-admin
([#589](#589))
([#594](#594))
([34032ac](34032ac))
* **audit:** show the price a line sold for, not its list price
([#759](#759))
([e6b37d0](e6b37d0))
* **audit:** store catalog enums by name and guard the add-item
transaction shape
([#751](#751))
([23609ff](23609ff))
* **auth:** reject invalid account claims
([#622](#622))
([8d6c7fe](8d6c7fe))
* **auth:** require step-up for durable user access
([#360](#360))
([#607](#607))
([f767dce](f767dce))
* **ci:** bound the npm audit calls and give the web job room to finish
([#686](#686))
([153b7a8](153b7a8))
* **ci:** escalate the audit bound to SIGKILL, so it actually bounds
([#686](#686))
([a0c8f4e](a0c8f4e))
* **ci:** fail closed on invalid vulnerability config
([#621](#621))
([1690db8](1690db8))
* **ci:** lockfix covers the two AppHost lock files, derived from the
sln
([efb05e6](efb05e6))
* **ci:** lockfix covers the two AppHost lock files, derived from the
sln
([8986d77](8986d77))
* **ci:** remove invalid XML comment from nuget.lockfix.config
([#541](#541))
([5f1bc0a](5f1bc0a))
* **ci:** the advisory vuln gate no longer blocks on an unusable report
([#686](#686))
([aaf6934](aaf6934))
* **ci:** the advisory vuln gate no longer blocks on an unusable report
([#686](#686))
([64f1f53](64f1f53))
* **i18n:** tl help text names the saleable flag and unit-system setting
what their labels call them
([#688](#688))
([#696](#696))
([bfd24d7](bfd24d7))
* **infra:** AccountId must be a non-nullable Guid or both tenant write
layers refuse ([#673](#673))
([#695](#695))
([2470c4e](2470c4e))
* require step-up for flock scope changes
([#609](#609))
([4151f89](4151f89))
* **sales:** keep a line's discount markers agreeing while its price is
edited ([#752](#752))
([#753](#753))
([c159b4b](c159b4b))
* **sales:** say which kind of missing list price a line has
([#774](#774))
([489180e](489180e))
* scope legacy logout to selected farm
([#624](#624))
([fae8d82](fae8d82))
* **seed:** drain the daily-entry lock sweep so deep simulation fixtures
validate ([#644](#644))
([730fa23](730fa23)),
closes [#638](#638)
* **tenancy:** AccountId is a concurrency token, so the database refuses
a detached cross-tenant write
([#562](#562))
([4d1dfa3](4d1dfa3))
* **tenancy:** AspNetUserRoles carries a tenant column, so a role write
naming another farm's user is refused
([#670](#670))
([fc0552a](fc0552a))
* **tests:** bump the image-pin allow-list counts for the AppHost
LocalPorts tests
([#593](#593))
([58d3056](58d3056))
* **tests:** the OTLP collector survives a lost port race and ignores
traffic that is not an export
([#672](#672),
[#676](#676))
([#677](#677))
([965c737](965c737))
* **web:** a scoped audit view filtered to nothing names both the record
and the range ([#666](#666))
([41bbfe1](41bbfe1))
* **web:** an abandoned dialog attempt's success no longer hijacks the
replacement on Customers, Daily Entry, Flocks, Grades and Products
([#703](#703))
([#705](#705))
([85605db](85605db))
* **web:** an abandoned dialog attempt's success no longer hijacks the
replacement on Inventory, Expenses, History and Stock
([#703](#703))
([#706](#706))
([60a4997](60a4997))
* **web:** an abandoned edit's success no longer hijacks the dialog that
replaced it on Users
([#703](#703))
([#710](#710))
([778faab](778faab))
* **web:** an abandoned order attempt's success no longer hijacks the
dialog that replaced it
([#702](#702))
([522c699](522c699))
* **web:** capture screens open on the flock you last used, and
assigning one no longer guesses
([#646](#646))
([#699](#699))
([7f8f317](7f8f317))
* **web:** constrain dialog session helpers to declared scopes
([#715](#715))
([389e3c8](389e3c8))
* **web:** date validation gets one boundary table instead of one case
per review round
([#666](#666))
([215f830](215f830))
* **web:** keep a paged window and an item panel on the user's newest
intent ([#645](#645))
([d81bccf](d81bccf))
* **web:** keep Sales order panels closed after pending writes
([#711](#711))
([f0f7492](f0f7492))
* **web:** keep Sales panels closed after pending Open reads
([#716](#716))
([620411f](620411f))
* **web:** make login take the cross-tab cookie lock so a racing refresh
cannot restore the wrong session
([#648](#648))
([ff18beb](ff18beb))
* **web:** make the entity picker read as a search field and focus it on
open ([#736](#736))
([66ef667](66ef667)),
closes [#735](#735)
* **web:** page truncated customer and movement tables with usePagedList
([7cfe4d6](7cfe4d6))
* **web:** reconcile Sales line edits with refreshed orders
([#717](#717))
([d7dd2c9](d7dd2c9))
* **web:** the audit date filter accepts low-numbered years, and its
empty state covers every narrowing
([#666](#666))
([af52d25](af52d25))
* **web:** the audit date filter rejects impossible dates, and its
history guard actually guards
([#666](#666))
([8d51846](8d51846))
* **web:** the expense range bounds are not capped at today, which the
month-end default exceeds
([#667](#667))
([7e01864](7e01864))
* **web:** the help text calls the expiry field what the field calls
itself ([#666](#666))
([2fd1f3c](2fd1f3c))
* **web:** the stock lot date range sits in the bounded toolbar
([#653](#653))
([43dec5e](43dec5e))


### Refactoring

* **web:** extract SalesPage's dialog-write wrapper into a shared
useDialogAction hook
([#703](#703))
([#704](#704))
([60ee9d9](60ee9d9))


### Documentation

* add k6 preparation steps to the dev-database fixture runbook
([#643](#643))
([a4f1f09](a4f1f09))
* add runbook for loading the simulation fixture into a dev database
([#639](#639))
([2d143b8](2d143b8))
* **agents:** a PR closes its issue from the body, not the title
([#744](#744))
([39be13c](39be13c))
* **agents:** drop the commit and push gate, and require screenshots on
UI changes ([#757](#757))
([6225172](6225172))
* **agents:** find guards by grepping registry readers; amend issues a
PR overtakes ([#580](#580))
([fe3fde8](fe3fde8))
* **agents:** the Playwright specs have been in CI since 2026-08-08
([#768](#768))
([68ee612](68ee612))
* **aspire:** record the second local database and pin the AppHost
dashboard ports ([#623](#623))
([713b941](713b941))
* compress AGENTS.md to one paragraph per rule, and draw the two orders
that matter ([#551](#551))
([997ae8a](997ae8a))
* item 7 names each screen's actual initial filter value
([#666](#666))
([70a53d8](70a53d8))
* multi-farm tenancy decision record and AGENTS/GLOSSARY sync
([#537](#537))
([#601](#601))
([2c34771](2c34771))
* name the scoped filtered-empty key and state the
[#653](#653) relationship
plainly ([#666](#666))
([0e93dac](0e93dac))
* note that a PackageReference in Directory.Build.props is invisible to
the dependency graph
([4845724](4845724))
* **plans:** commit the
[#722](#722) and
[#745](#745) design records
([#754](#754))
([c942fcd](c942fcd))
* record [#579](#579) as
won't-fix — suspension is immediate for use, not issuance
([#582](#582))
([7a3be40](7a3be40))
* record the [#508](#508)
audit ordering key and the tracked-file guard lesson
([#701](#701))
([08964e9](08964e9))
* **runbooks:** add procedure to rename the default farm's code after
upgrade ([#731](#731))
([2f6e242](2f6e242))
* screenshots of the running SPA in the README
([#550](#550))
([711488a](711488a))
* **sim:** commit the dashboard screenshot, capture the palette matrix,
and record the
[#651](https://github.com/mforce/cluckwork/issues/651)/[#652](https://github.com/mforce/cluckwork/issues/652)
conventions ([#660](#660),
[#662](#662),
[#663](#663),
[#664](#664))
([#665](#665))
([930ea30](930ea30))
* specify searchable entity picker
([#641](#641))
([91d4300](91d4300))
* split the README into audience-scoped docs and adopt repo-template
scaffolding ([#548](#548))
([b3f3fcf](b3f3fcf))
* surface Aspire local development workflow
([#568](#568))
([a343baa](a343baa))
* **web:** record the per-screen idempotency-key policies and runWrite's
refresh contract
([#703](#703))
([#707](#707))
([8bee651](8bee651))
* **web:** the date-cap help text covers every stocked item, not only
feed ([#666](#666),
[#667](#667))
([c8433c5](c8433c5))
* **web:** the help text claims only what is true of recording, and says
nothing about filter caps
([#666](#666),
[#667](#667))
([e2f63d1](e2f63d1))
* **web:** the help text describes the date-range filters that shipped
([#666](#666),
[#667](#667))
([c3275b7](c3275b7))
* **web:** the help text stops describing a cap the filters no longer
have ([#666](#666),
[#667](#667))
([49654cd](49654cd))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: cluckwork-lockfix[bot] <309265648+cluckwork-lockfix[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sales: discount markers drop out under inline edit, and three rounding/edge gaps in the discount treatment

1 participant