Only zero is empty, and the last day is worth reading

This commit is contained in:
2026-08-21 11:57:17 -04:00
parent bbda5cbfe9
commit 74392ccad2
7 changed files with 317 additions and 57 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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: '<a><slot /></a>' } } },
})
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('-')
})
})

View File

@@ -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)

View File

@@ -1,12 +1,13 @@
<template>
<div class="code-generator">
<div class="no-print">
<div class="page-header">
<h1>Barcode / QR Generator</h1>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<div class="page-header no-print">
<h1>Barcode / QR Generator</h1>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<div class="controls card">
<div class="workspace">
<div class="options no-print">
<div class="controls card">
<!-- What to encode -->
<div class="control-row">
<label>
@@ -285,33 +286,52 @@
{{ overlong.length }} row(s) could not be encoded as CODE128 or were empty:
{{ overlong.slice(0, 3).join(', ') }}{{ overlong.length > 3 ? ' ...' : '' }}
</p>
</div>
</div>
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
<div class="sheet">
<div
v-for="item in visible"
:key="item.index"
class="label"
:class="labelClasses(item.page)"
>
<img v-if="showPicture(item.page) && pictureposition === 'behind'"
:src="pictureUrl" class="picture-behind" alt="" />
<div class="content">
<div v-if="showPicture(item.page) && pictureposition !== 'behind'" class="picturebox">
<img :src="pictureUrl" class="picture-img" alt="" />
</div>
<div class="codegroup">
<div v-if="layoutFor(item.page) !== 'textonly'" class="codebox">
<img v-if="images[item.index]" :src="images[item.index]" class="code-img" alt="" />
</div>
<div v-if="layoutFor(item.page) !== 'codeonly' && item.page.label" class="label-text">
{{ item.page.label }}
</div>
</div>
</div>
<span v-if="backenabled" class="side-flag">{{ item.page.side }}</span>
</div>
<!-- Preview doubles as the print surface: what is on screen is what
prints, so everything around it is chrome that print must strip. -->
<div class="preview">
<div class="preview-head no-print">
<h4 class="section-heading">Preview</h4>
<span class="preview-count">
{{ cardCount }} label{{ cardCount === 1 ? '' : 's' }}<span
v-if="backenabled">, {{ pages.length }} pages</span>
</span>
</div>
<div class="sheet">
<div
v-for="item in visible"
:key="item.index"
class="label"
:class="labelClasses(item.page)"
>
<img v-if="showPicture(item.page) && pictureposition === 'behind'"
:src="pictureUrl" class="picture-behind" alt="" />
<div class="content">
<div v-if="showPicture(item.page) && pictureposition !== 'behind'" class="picturebox">
<img :src="pictureUrl" class="picture-img" alt="" />
</div>
<div class="codegroup">
<div v-if="layoutFor(item.page) !== 'textonly'" class="codebox">
<img v-if="images[item.index]" :src="images[item.index]" class="code-img" alt="" />
<!-- An empty code box is indistinguishable from a label that
simply has no code on it, which is how a failed render
reads as "the barcode does not appear". Say so, on screen
only - the printed label must never carry this. -->
<span v-else class="code-missing">no code</span>
</div>
<div v-if="layoutFor(item.page) !== 'codeonly' && item.page.label" class="label-text">
{{ item.page.label }}
</div>
</div>
</div>
<span v-if="backenabled" class="side-flag">{{ item.page.side }}</span>
</div>
<p v-if="!pages.length" class="preview-empty no-print">
Nothing to print yet - enter content above.
</p>
</div>
</div>
</div>
</div>
@@ -732,6 +752,47 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
.code-generator { width: 100%; }
/* Settings on the left, the labels themselves on the right, where they stay in
view while the settings that change them are being worked. The preview
column scrolls on its own so a 500-label run cannot push the controls off
the screen. */
.workspace {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
gap: 20px;
align-items: start;
}
.preview {
position: sticky;
top: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px 16px;
max-height: calc(100vh - var(--main-pad-top) - 24px);
overflow: auto;
}
.preview-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.preview-head .section-heading { margin-top: 0.25rem; }
.preview-count { font-size: 0.8125rem; color: var(--text-light); }
.preview-empty { color: var(--text-light); font-size: 0.8125rem; margin: 0; }
/* One column once the two would be too narrow to work in. */
@media (max-width: 1200px) {
.workspace { grid-template-columns: minmax(0, 1fr); }
.preview { position: static; max-height: none; }
}
.controls {
background: var(--bg-card);
color: var(--text);
@@ -905,18 +966,35 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
.label.type-barcode .codebox {
flex: 1 1 auto;
width: auto;
min-width: 0;
/* Never below 40% of the label: past that a CODE128 is too few narrow bars
to scan, so it is better for the caption to wrap or clip. */
min-width: 40%;
height: var(--tool-barh);
}
.label.layout-stack.type-barcode .codebox { min-width: 100%; }
/* Screen-only, and deliberately loud: an empty box means the render failed. */
.code-missing {
font-family: Arial, Helvetica, sans-serif;
font-size: 6px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--danger);
}
.code-img {
width: 100%;
height: 100%;
object-fit: contain;
}
/* The caption takes what it needs and no more. Both boxes used to grow, so a
caption of any length halved the barcode and then kept going - at 1x0.5in
with a couple of words it left a 36px stub that reads as "the code did not
render". A code that cannot be scanned is not a label. */
.label-text {
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 0;
text-align: center;
font-family: "Arial Narrow", Arial, Helvetica, sans-serif;
@@ -943,7 +1021,20 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
@media print {
.side-flag { display: none !important; }
.code-missing { display: none !important; }
.sheet { padding: 0; display: block; gap: 0; }
/* The preview column is chrome. Its overflow matters most: a scrolling
ancestor clips a multi-page print to whatever was on screen. */
.workspace { display: block; }
.preview {
position: static;
max-height: none;
overflow: visible;
border: 0;
border-radius: 0;
padding: 0;
background: none;
}
/* The back's dotted variant outspecifies a bare .label reset, so it printed
a dashed rule down the edge of every second card. */
.label,

View File

@@ -8,6 +8,8 @@ purchasing decision.
from datetime import datetime, timedelta, timezone
import pytest
from plugins.printers.services.supply_history import (
analyse, band, burn_rate, current_run, find_replacements, normalise,
orderlist, soonest,
@@ -113,28 +115,69 @@ def test_live_level_drives_the_countdown_not_the_stored_one():
assert result['daysleft'] == 1 # 20 / 20, not 40 / 20
def test_a_nearly_empty_cartridge_reads_as_empty_not_as_weeks_away():
"""1% draining a tenth of a point a day computes to ten days. It is out."""
result = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=1)
def test_a_nearly_empty_cartridge_keeps_its_real_countdown():
"""Only zero is empty.
This used to floor everything at or below 5% to daysleft 0, so 1% draining
a tenth of a point a day was reported as gone. It has ten days in it, and
ten days is the only thing the row exists to say. Flooring it put the
cartridge beside ones that genuinely are empty with no way back to the
difference.
"""
# 3.0 down to 1.0 at a tenth of a point a day: a real, slow, measured drop.
slow = [round(3.0 - 0.1 * i, 2) for i in range(21)]
result = analyse(series(slow), currentlevel=1)
assert result['daysleft'] == pytest.approx(10, abs=0.1)
def test_a_cartridge_with_hours_left_is_not_reported_as_empty():
"""The other half of the floor: int() truncated the division, so anything
under a full day became 0 and read as empty. A cartridge with six hours in
it still prints."""
result = analyse(series([100, 75, 50, 25]), currentlevel=6)
assert 0 < result['daysleft'] < 1
assert round(result['daysleft'] * 24) == 6
def test_an_actual_zero_is_still_empty():
"""The half that must not regress. Zero means spent, and spent is ordered
first."""
result = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=0)
assert result['daysleft'] == 0
def test_empty_sorts_ahead_of_a_fast_healthy_cartridge():
empty = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=1)
empty = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=0)
healthy = analyse(series([100, 80, 60, 40]))
assert empty['daysleft'] < healthy['daysleft']
def test_a_low_live_level_is_actionable_without_any_history():
def test_an_empty_live_level_is_actionable_without_any_history():
"""A printer new to Zabbix still reports it is out of toner."""
result = analyse([], currentlevel=1)
result = analyse([], currentlevel=0)
assert result['daysleft'] == 0
assert result['reason'] is None
def test_a_low_live_level_with_no_history_cannot_be_counted_down():
"""A rate needs two readings. At 1% with none, there is nothing to divide.
Previously the 5% floor answered this by asserting daysleft 0, which was
right by accident and wrong in general - it said "empty" about a level
nobody had watched move. It reports no estimate and says why; the report
lists it under "No estimate yet" rather than on the order list.
"""
result = analyse([], currentlevel=1)
assert result['daysleft'] is None
assert result['reason'] == 'no history'
def test_no_history_and_a_healthy_level_still_says_no_history():
result = analyse([], currentlevel=80)

View File

@@ -16,9 +16,13 @@ from plugins.printers.models import ModelSupply, Printer
LEVELS = {
# itemid: (name, live level, history of daily readings)
'10': ('Black Cartridge', 1, [6, 5, 4, 3]),
# Black is a REAL zero, which is the only thing that means empty. Magenta is
# low but not zero and keeps its own countdown - under a day here, which the
# old int() truncation collapsed to 0 and reported as empty alongside black.
'10': ('Black Cartridge', 0, [6, 5, 4, 3]),
'11': ('Cyan Cartridge', 40, [100, 90, 80, 70, 60, 50, 40]),
'12': ('Yellow Cartridge', 92, [95, 94, 93, 92]),
'13': ('Magenta Cartridge', 3, [100, 76, 52, 28]),
}
START = 1780000000
@@ -111,7 +115,8 @@ def test_a_row_is_a_cartridge_not_a_printer(client, printer_with_parts):
data = forecast(client)
names = sorted(c['name'] for c in data['cartridges'] + data['unestimated'])
assert names == ['Black Cartridge', 'Cyan Cartridge', 'Yellow Cartridge']
assert names == ['Black Cartridge', 'Cyan Cartridge', 'Magenta Cartridge',
'Yellow Cartridge']
def test_each_cartridge_carries_its_own_countdown(client, printer_with_parts):
@@ -120,18 +125,30 @@ def test_each_cartridge_carries_its_own_countdown(client, printer_with_parts):
data = forecast(client)
bylevel = {c['name']: c for c in data['cartridges']}
assert bylevel['Black Cartridge']['daysleft'] == 0 # live level 1%
assert bylevel['Black Cartridge']['daysleft'] == 0 # live level 0%
assert bylevel['Cyan Cartridge']['daysleft'] == 4 # 40% at 10%/day
assert len({c['daysleft'] for c in data['cartridges']}) > 1
def test_a_low_cartridge_keeps_a_countdown_measured_in_hours(client,
printer_with_parts):
"""3% at 24%/day is about three hours. It used to arrive as 0 - int()
truncated the division - so it was indistinguishable from black, which is
genuinely spent."""
data = forecast(client)
magenta = {c['name']: c for c in data['cartridges']}['Magenta Cartridge']
assert 0 < magenta['daysleft'] < 1
assert round(magenta['daysleft'] * 24) == 3
def test_cartridges_are_banded_by_urgency(client, printer_with_parts):
data = forecast(client)
byname = {c['name']: c['band'] for c in data['cartridges']}
assert byname['Black Cartridge'] == 'empty'
assert byname['Cyan Cartridge'] == 'soon'
assert data['summary']['bands']['empty'] == 1
assert data['summary']['bands']['empty'] == 1, 'only the real zero'
def test_the_order_list_names_the_part_and_the_quantity(client, printer_with_parts):