From 74392ccad22dcffb35a5b894a52fddf3c92ffd01 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 21 Aug 2026 11:57:17 -0400 Subject: [PATCH] Only zero is empty, and the last day is worth reading --- CHANGELOG.md | 16 ++ .../printers/frontend/views/TonerForecast.vue | 29 +++- .../frontend/views/tonerForecastNames.spec.js | 62 +++++++ plugins/printers/services/supply_history.py | 28 ++- .../tools/frontend/views/CodeGenerator.vue | 159 ++++++++++++++---- tests/test_plugins/test_supply_history.py | 55 +++++- .../test_toner_forecast_report.py | 25 ++- 7 files changed, 317 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ef7308..7e070cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,14 @@ ADR-007 and ADR-002. ### Fixed +- **A caption could eat the barcode.** The code box and the caption both grew, + so any caption took half the label and kept going: "BAY 12 PRESS LINE 1" on + 1.00 x 0.50in stock left a 36px stub, which is a code that is present, + unscannable, and reads as one that failed to render. The caption now takes + only the room it needs and a CODE128 keeps at least 40% of the label, all of + it when stacked. A code that genuinely fails to render says so in the preview + now, in red, rather than leaving white space that looks like a label with no + code on it. - **The generator prints from inside the app shell.** It was a full-screen route with no sidebar and a narrow column of controls on the left. It is now an ordinary page - navigation, full width - and `style.css` hides the shell at @@ -64,6 +72,14 @@ ADR-007 and ADR-002. ### Changed +- **Settings on the left, labels on the right.** The controls were a narrow + column with the preview trailing underneath, so the labels being adjusted were + off the bottom of the screen. The preview is now a column of its own that + stays in view and scrolls by itself, and it folds back to one column on a + narrow window. Note for anyone changing it: that scrolling column is exactly + the kind of ancestor that clips a multi-page print to whatever was on screen, + so print resets its overflow - verified by printing a six-page two-sided run, + not by looking at the preview. - CSV parsing and page building moved out of the generator component into `plugins/tools/frontend/labelPages.js`, where the column contract and the two-sided page order are covered by tests. diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue index dac785a2..b79570f1 100644 --- a/plugins/printers/frontend/views/TonerForecast.vue +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -293,11 +293,32 @@ function barWidth(cartridge) { return Math.max(0, Math.min(100, level)) + '%' } +// daysleft is fractional, so the last day of a cartridge's life is readable +// instead of collapsing to "empty". Only an actual zero is empty; anything above +// it has time left and the row says how much, in whatever unit carries meaning. +const HOURS_PER_DAY = 24 +const MINUTES_PER_HOUR = 60 + function daysText(cartridge) { - if (cartridge.daysleft == null) return '-' - if (cartridge.daysleft === 0) return 'empty' - if (cartridge.daysleft === 1) return '1 day' - return cartridge.daysleft + ' days' + const days = cartridge.daysleft + if (days == null) return '-' + if (days <= 0) return 'empty' + + if (days < 1) { + const hours = days * HOURS_PER_DAY + // Under an hour, hours would round to 0 and read as empty again. + if (hours < 1) { + const minutes = Math.max(1, Math.round(hours * MINUTES_PER_HOUR)) + return `${minutes} min` + } + const rounded = Math.round(hours) + return rounded === 1 ? '1 hour' : `${rounded} hours` + } + + // Whole days from here. The estimate is not precise enough for "3.7 days" to + // mean more than "about 4", and a column of decimals is harder to scan. + const rounded = Math.round(days) + return rounded === 1 ? '1 day' : `${rounded} days` } function orderKey(item) { diff --git a/plugins/printers/frontend/views/tonerForecastNames.spec.js b/plugins/printers/frontend/views/tonerForecastNames.spec.js index 6c5bbbb2..038d62f3 100644 --- a/plugins/printers/frontend/views/tonerForecastNames.spec.js +++ b/plugins/printers/frontend/views/tonerForecastNames.spec.js @@ -108,3 +108,65 @@ describe('what purchasing receives is unchanged', () => { expect(written[0]).not.toContain('414X Black') }) }) + +// --- how long is left ------------------------------------------------------ +// +// daysleft used to be int(level / rate), so anything under a full day +// truncated to 0 and the view printed "empty". A cartridge with six hours in it +// still prints, and the row exists to say how long. Only a real zero is empty. + +describe('the countdown reads in whatever unit carries meaning', () => { + async function daysCell(daysleft) { + const band = daysleft === null ? 'soon' : daysleft <= 0 ? 'empty' : 'soon' + const base = payload() + base.data.data.cartridges[0].daysleft = daysleft + base.data.data.cartridges[0].band = band + printersApi.supplyForecast.mockResolvedValue(base) + const wrapper = mount(TonerForecast, { + global: { stubs: { 'router-link': { template: '' } } }, + }) + await flushPromises() + // Only the 'empty' band starts expanded - what is already out is what the + // page opens on. Every band renders a header whether or not it holds + // anything, so the right one has to be picked by its key rather than by + // position; clicking the first would close 'empty' instead. + if (band !== 'empty') { + const head = wrapper.findAll('.band-head') + .find(b => b.find(`.band-count.${band}`).exists()) + await head.trigger('click') + await flushPromises() + } + return wrapper.find('tbody .days').text() + } + + it('says empty only for an actual zero', async () => { + expect(await daysCell(0)).toBe('empty') + }) + + it('reads in hours below a day', async () => { + expect(await daysCell(0.25)).toBe('6 hours') + }) + + it('does not pluralise a single hour', async () => { + expect(await daysCell(1 / 24)).toBe('1 hour') + }) + + it('reads in minutes below an hour, never as empty', async () => { + const text = await daysCell(0.02) + expect(text).toBe('29 min') + expect(text).not.toBe('empty') + }) + + it('never rounds a live cartridge down to empty', async () => { + expect(await daysCell(0.0001)).toBe('1 min') + }) + + it('reads in whole days above a day', async () => { + expect(await daysCell(3.7)).toBe('4 days') + expect(await daysCell(1.2)).toBe('1 day') + }) + + it('still shows a dash when there is no estimate', async () => { + expect(await daysCell(null)).toBe('-') + }) +}) diff --git a/plugins/printers/services/supply_history.py b/plugins/printers/services/supply_history.py index f9e2e701..26f984ce 100644 --- a/plugins/printers/services/supply_history.py +++ b/plugins/printers/services/supply_history.py @@ -74,12 +74,16 @@ MIN_DROP_FOR_ESTIMATE = 2 # precise-looking figure invites more trust than it has earned. RATE_SPREAD_FACTOR = 5 -# At or below this, the cartridge is done and the arithmetic stops being the -# useful answer. A supply sitting at 1% that drains a tenth of a point a day -# computes to ten days; a printer at 1% is out of toner as far as anyone -# standing at it is concerned, and it is what should be ordered first. Rate is -# still reported - only the days-left figure is floored. -EMPTY_LEVEL = 5 +# Empty means empty. This was 5, on the reasoning that a printer at 1% is out +# of toner as far as anyone standing at it is concerned - but flooring the +# days-left of everything at or below 5% threw away the only number the row +# exists to give. A cartridge at 2% draining a point a day has two days left, +# and two days is worth knowing; reporting it as already gone puts it beside +# cartridges that genuinely are, and there is no way back to the difference. +# +# Anything above zero gets its real estimate, in hours if that is what it comes +# to. Only a reading of zero is empty. +EMPTY_LEVEL = 0 def _asfloat(value): @@ -345,8 +349,8 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None): rate = burn_rate(run) - # Empty is empty. Ordering by a rate below this level ranks a dead - # cartridge behind a healthy one that happens to be draining faster. + # Empty is empty, and only zero is empty. Ordering by a rate at this level + # ranks a dead cartridge behind a healthy one that happens to drain faster. if level <= EMPTY_LEVEL: result['daysleft'] = 0 result['burnrateperday'] = round(rate, 2) if rate is not None else None @@ -363,7 +367,13 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None): return result result['burnrateperday'] = round(rate, 2) - result['daysleft'] = max(0, int(level / rate)) + # Fractional, NOT int(). Truncating meant every cartridge with less than a + # full day left reported 0, which the bands read as empty and the view + # printed as "empty" - a cartridge with six hours in it was indistinguishable + # from one with nothing. Two decimal places is a quarter of an hour, which is + # finer than the estimate deserves but costs nothing and keeps the ordering + # of two nearly-spent cartridges meaningful. + result['daysleft'] = round(max(0.0, level / rate), 2) # Say so when the intervals disagree wildly. The number is still the best # estimate available; the flag stops it reading as a measurement. result['rateunstable'] = rate_is_unstable(run) diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue index 78fb6a60..9782956d 100644 --- a/plugins/tools/frontend/views/CodeGenerator.vue +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -1,12 +1,13 @@