Add "Count by Weight" to estimate item counts from weighing

Counting bulk hardware (screws, nails) by hand is slow.  This adds a workflow
to estimate the count from weight instead: weigh one item, weigh the lot, and
the count is filled in automatically.

- The Fields modal gains a "Count by Weight" button that adds two quantity
  fields, Unit Weight and Total Weight (default grams), and tags the Count
  field as computed.  An existing `count` field is linked in place rather than
  duplicated, so hand-counted items keep their values.
- The link is plain metadata on the Count field
  (`compute: {kind: 'count_by_weight', total, unit}`), so it round-trips
  through the config/API with no backend change (covered by a backend test).
- During item entry, Count = round(Total Weight / Unit Weight), converting
  mismatched units first (g/kg/oz) and refusing to divide when only one side
  has a unit.  It rounds to a whole number (no 403.7 nails).
- When a weight changes, the count always reflects the new reality: it is set
  to the computed value (0 for a sub-unit total) when both weights are present,
  or cleared when they can't produce one (a weight blanked, incompatible
  units) — so a stale count can never persist against edited weights.  A
  hand-typed count is only touched when a weight actually changes.

countByWeight lives in units.js; the field preset, config detection, and the
entry-time apply hook live in a new computeFields.js.  Covered by unit tests
(countByWeight, computeFields), InventoryTable auto-fill / clear / sub-unit on
draft and inline edit, the FieldSchemaEditor preset, and a backend
field-metadata round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-06-19 14:36:26 -06:00
parent 447e4a40df
commit 305c73921e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
9 changed files with 362 additions and 3 deletions

View file

@ -4,6 +4,7 @@ import {
} from "semantic-ui-react";
import {Button, Icon, Modal, Table} from "../Theme";
import {ALL_UNITS} from "./units";
import {addCountByWeightFields, isCountByWeight} from "./computeFields";
const FIELD_TYPE_OPTIONS = ['text', 'number', 'quantity', 'date', 'select', 'location', 'calories']
.map(t => ({key: t, value: t, text: t}));
@ -39,6 +40,8 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
return next;
});
const add = () => setDraft(prev => [...prev, {key: '', label: '', type: 'text'}]);
// Add the "count by weight" fields (Unit Weight, Total Weight, computed Count); links an existing Count field.
const addCountByWeight = () => setDraft(prev => addCountByWeightFields(prev));
const save = async () => {
// Fill any missing keys from labels and normalize order.
@ -108,6 +111,10 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
<Input fluid placeholder='comma,separated,options'
value={(f.options || []).join(',')}
onChange={e => update(index, {options: e.target.value.split(',')})}/>}
{isCountByWeight(f) &&
<span style={{opacity: 0.7, fontSize: '0.9em'}}>
<Icon name='calculator'/> Auto-counted: Total Weight ÷ Unit Weight
</span>}
</TableCell>
<TableCell collapsing textAlign='center'>
<Checkbox toggle checked={!!f.mobile} aria-label={`Show ${f.label || f.key} on mobile`}
@ -121,6 +128,9 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
</TableBody>
</Table>
<Button onClick={add}><Icon name='plus'/>Add Field</Button>
<Button onClick={addCountByWeight} title='Add Unit Weight, Total Weight, and an auto-counted Count'>
<Icon name='balance scale'/>Count by Weight
</Button>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>

View file

@ -20,6 +20,24 @@ describe('FieldSchemaEditor select options', () => {
expect(optionsInput.value).toBe('screws,nails');
});
test('"Count by Weight" adds the linked weight + computed-count fields and they persist on save', async () => {
const onSave = jest.fn();
render(<FieldSchemaEditor fields={[{key: 'name', label: 'Name', type: 'text'}]}
open={true} onClose={jest.fn()} onSave={onSave}/>);
fireEvent.click(screen.getByText('Count by Weight'));
// The note appears on the computed Count row.
expect(screen.getByText(/Total Weight ÷ Unit Weight/)).toBeTruthy();
fireEvent.click(screen.getByText('Save Fields'));
const saved = onSave.mock.calls[0][0];
const byKey = Object.fromEntries(saved.map(f => [f.key, f]));
expect(byKey.unit_weight).toMatchObject({type: 'quantity', unit: 'g'});
expect(byKey.total_weight).toMatchObject({type: 'quantity', unit: 'g'});
// The compute metadata survives save() so the table can auto-fill the count.
expect(byKey.count.compute).toEqual({kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'});
});
test('options are trimmed and emptied-out on save', async () => {
const onSave = jest.fn();
render(<FieldSchemaEditor fields={FIELDS} open={true} onClose={jest.fn()} onSave={onSave}/>);

View file

@ -2,6 +2,7 @@ import React, {useMemo, useRef, useState} from "react";
import {Checkbox, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Icon, Table} from "../Theme";
import {FieldCell} from "./FieldCell";
import {applyComputedCounts, computedCountConfigs} from "./computeFields";
const LOCATION_LIST_ID = 'inventory-location-suggestions';
const CATALOG_LIST_ID = 'inventory-catalog-names';
@ -80,6 +81,8 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
});
return keys;
}, [fields]);
// "Count by weight" configs: a Count field auto-filled from a Total Weight ÷ Unit Weight.
const countConfigs = useMemo(() => computedCountConfigs(fields), [fields]);
const listIdFor = (field) => {
if (field.type === 'location') {
@ -147,6 +150,8 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
});
}
}
// Auto-fill any "count by weight" field when a weight (or its unit) changed.
applyComputedCounts(next, countConfigs, key);
return next;
});
@ -165,7 +170,11 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
setFocusCell({id: item.id, key}); // the FieldCell for this key auto-focuses and selects its contents
};
const setEditValue = (id, key, value) =>
setEdits(prev => ({...prev, [id]: {...prev[id], [key]: value}}));
setEdits(prev => {
const updated = {...prev[id], [key]: value};
applyComputedCounts(updated, countConfigs, key);
return {...prev, [id]: updated};
});
const saveEdit = (id) => {
onChange(items.map(i => i.id === id ? edits[id] : i));

View file

@ -1,5 +1,5 @@
import React from "react";
import {fireEvent, render, screen} from "@testing-library/react";
import {fireEvent, render, screen, within} from "@testing-library/react";
import {filterItems, InventoryTable} from "./InventoryTable";
const FIELDS = [
@ -78,6 +78,64 @@ describe('InventoryTable keyboard entry', () => {
});
});
describe('InventoryTable count by weight', () => {
const FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'unit_weight', label: 'Unit Weight', type: 'quantity', unit: 'g', order: 1},
{key: 'total_weight', label: 'Total Weight', type: 'quantity', unit: 'g', order: 2},
{key: 'count', label: 'Count', type: 'number', order: 3,
compute: {kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}},
];
test('entering the two weights in the draft row auto-fills the count', () => {
const onChange = jest.fn();
render(<InventoryTable slug='s' fields={FIELDS} items={[]} locations={[]} onChange={onChange}/>);
fireEvent.change(screen.getByLabelText('Unit Weight'), {target: {value: '5'}});
fireEvent.change(screen.getByLabelText('Total Weight'), {target: {value: '1000'}});
// The draft Count input is now pre-filled with 200 (1000 / 5).
expect(screen.getByLabelText('Count').value).toBe('200');
});
test('editing an existing item recomputes its count from the weights', () => {
const onChange = jest.fn();
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={onChange}/>);
fireEvent.click(screen.getByText('Screws')); // enter edit mode
const totalInput = screen.getAllByLabelText('Total Weight').find(i => i.value === '1000');
fireEvent.change(totalInput, {target: {value: '2000'}});
// The edited row's Count is recomputed to 400 (2000 / 5).
expect(screen.getAllByLabelText('Count').some(i => i.value === '400')).toBe(true);
});
test('clearing a weight in an existing row blanks the count (no stale value)', () => {
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Screws')); // enter edit mode
const row = screen.getByDisplayValue('Screws').closest('tr');
// Clearing the total weight (as a real keyboard delete does) fires onChange with '' and blanks the count.
fireEvent.change(within(row).getByLabelText('Total Weight'), {target: {value: ''}});
expect(within(row).getByLabelText('Count').value).toBe('');
});
test('reducing a weight below one item shows 0, not the stale count', () => {
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Screws'));
const row = screen.getByDisplayValue('Screws').closest('tr');
fireEvent.change(within(row).getByLabelText('Total Weight'), {target: {value: '1'}});
expect(within(row).getByLabelText('Count').value).toBe('0');
});
});
describe('InventoryTable sorting', () => {
const items = [
{id: 1, name: 'Rice', count: '5'},

View file

@ -0,0 +1,91 @@
import {countByWeight} from "./units";
// "Count by weight" computed fields. A Count (number) field can carry metadata linking it to a Total Weight and a
// Unit Weight field; the table then fills the count automatically from the two weights (count = round(total / unit)).
// The metadata is a plain property on the field dict, so it round-trips through the config/API unchanged.
export const COUNT_BY_WEIGHT = 'count_by_weight';
// The fields the "Count by weight" preset creates.
const UNIT_WEIGHT = {key: 'unit_weight', label: 'Unit Weight', type: 'quantity', unit: 'g'};
const TOTAL_WEIGHT = {key: 'total_weight', label: 'Total Weight', type: 'quantity', unit: 'g'};
const COUNT_COMPUTE = {kind: COUNT_BY_WEIGHT, total: 'total_weight', unit: 'unit_weight'};
// True when a field is a Count computed from weights.
export function isCountByWeight(field) {
return !!field && field.compute && field.compute.kind === COUNT_BY_WEIGHT;
}
/**
* Add the "Count by weight" fields to a schema: Unit Weight and Total Weight (quantity, grams), plus a Count that
* is tagged as computed. An existing `count` field is linked in place (so a Tool/Fastener inventory's Count just
* becomes computed) rather than duplicated; existing weight fields are left untouched. Returns a new field list.
*/
export function addCountByWeightFields(fields) {
const list = (fields || []).map(f => ({...f}));
const has = (key) => list.some(f => f.key === key);
if (!has(UNIT_WEIGHT.key)) {
list.push({...UNIT_WEIGHT});
}
if (!has(TOTAL_WEIGHT.key)) {
list.push({...TOTAL_WEIGHT});
}
const existingCount = list.find(f => f.key === 'count');
if (existingCount) {
existingCount.compute = {...COUNT_COMPUTE};
if (existingCount.type !== 'number') {
existingCount.type = 'number';
}
} else {
list.push({key: 'count', label: 'Count', type: 'number', compute: {...COUNT_COMPUTE}});
}
return list;
}
/**
* The count-by-weight configs present in a schema, each resolved to the weight fields' keys and default units:
* [{countKey, totalKey, unitKey, totalUnit, unitUnit}]. Skips configs whose referenced weight fields are missing.
*/
export function computedCountConfigs(fields) {
return (fields || [])
.filter(isCountByWeight)
.map(f => {
const totalField = (fields || []).find(x => x.key === f.compute.total);
const unitField = (fields || []).find(x => x.key === f.compute.unit);
if (!totalField || !unitField) {
return null;
}
return {
countKey: f.key,
totalKey: totalField.key,
unitKey: unitField.key,
totalUnit: totalField.unit || '',
unitUnit: unitField.unit || '',
};
})
.filter(Boolean);
}
/**
* Recompute computed counts on an item after its field `changedKey` changed, mutating and returning the item.
* Only acts when the change was to one of a count's weight inputs (value or unit), so a hand-typed count survives
* edits to unrelated fields and is only touched when a weight actually changes. When a weight changes, the count
* always reflects the new reality: it is set to the computed value (0 included) when both weights are present, or
* cleared when they can't produce one (a weight blanked, incompatible units) — so a stale count can never persist
* against edited weights.
*/
export function applyComputedCounts(item, configs, changedKey) {
(configs || []).forEach(c => {
const triggers = [c.totalKey, c.unitKey, `${c.totalKey}_unit`, `${c.unitKey}_unit`];
if (!triggers.includes(changedKey)) {
return;
}
const count = countByWeight(
item[c.totalKey], item[`${c.totalKey}_unit`] || c.totalUnit,
item[c.unitKey], item[`${c.unitKey}_unit`] || c.unitUnit,
);
item[c.countKey] = count === null ? '' : String(count);
});
return item;
}

View file

@ -0,0 +1,92 @@
import {addCountByWeightFields, applyComputedCounts, computedCountConfigs, isCountByWeight} from "./computeFields";
describe('addCountByWeightFields', () => {
test('adds Unit Weight, Total Weight, and a computed Count to a schema with no count', () => {
const result = addCountByWeightFields([{key: 'name', label: 'Name', type: 'text'}]);
const byKey = Object.fromEntries(result.map(f => [f.key, f]));
expect(byKey.unit_weight).toMatchObject({type: 'quantity', unit: 'g'});
expect(byKey.total_weight).toMatchObject({type: 'quantity', unit: 'g'});
expect(byKey.count.type).toBe('number');
expect(byKey.count.compute).toEqual({kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'});
});
test('links an existing count field instead of duplicating it', () => {
const fields = [
{key: 'name', label: 'Name', type: 'text'},
{key: 'count', label: 'Count', type: 'number'},
];
const result = addCountByWeightFields(fields);
expect(result.filter(f => f.key === 'count')).toHaveLength(1);
expect(isCountByWeight(result.find(f => f.key === 'count'))).toBe(true);
// The two weight fields were appended.
expect(result.map(f => f.key)).toEqual(['name', 'count', 'unit_weight', 'total_weight']);
});
test('does not duplicate existing weight fields', () => {
const fields = [{key: 'unit_weight', type: 'quantity', unit: 'oz'}];
const result = addCountByWeightFields(fields);
expect(result.filter(f => f.key === 'unit_weight')).toHaveLength(1);
expect(result.find(f => f.key === 'unit_weight').unit).toBe('oz'); // left untouched
});
});
describe('computedCountConfigs', () => {
const FIELDS = [
{key: 'unit_weight', type: 'quantity', unit: 'g'},
{key: 'total_weight', type: 'quantity', unit: 'g'},
{key: 'count', type: 'number', compute: {kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}},
];
test('resolves the weight keys and their default units', () => {
expect(computedCountConfigs(FIELDS)).toEqual([
{countKey: 'count', totalKey: 'total_weight', unitKey: 'unit_weight', totalUnit: 'g', unitUnit: 'g'},
]);
});
test('skips a config whose referenced weight field is missing', () => {
const broken = [{key: 'count', type: 'number',
compute: {kind: 'count_by_weight', total: 'gone', unit: 'unit_weight'}}];
expect(computedCountConfigs(broken)).toEqual([]);
});
});
describe('applyComputedCounts', () => {
const configs = [{countKey: 'count', totalKey: 'total_weight', unitKey: 'unit_weight',
totalUnit: 'g', unitUnit: 'g'}];
test('fills the count when a weight changes', () => {
const item = {total_weight: '1000', unit_weight: '5'};
applyComputedCounts(item, configs, 'total_weight');
expect(item.count).toBe('200');
});
test('uses the per-item unit override when present', () => {
const item = {total_weight: '2', total_weight_unit: 'kg', unit_weight: '5', unit_weight_unit: 'g'};
applyComputedCounts(item, configs, 'unit_weight');
expect(item.count).toBe('400');
});
test('does not recompute when an unrelated field changes (manual count survives)', () => {
const item = {total_weight: '1000', unit_weight: '5', count: '999', name: 'Screws'};
applyComputedCounts(item, configs, 'name');
expect(item.count).toBe('999');
});
test('reducing a weight to a sub-unit total recomputes the count to 0 (no stale value)', () => {
const item = {total_weight: '1', unit_weight: '5', count: '200'};
applyComputedCounts(item, configs, 'total_weight');
expect(item.count).toBe('0');
});
test('clearing a weight clears the count so a stale value cannot persist', () => {
const item = {total_weight: '', unit_weight: '5', count: '200'};
applyComputedCounts(item, configs, 'total_weight');
expect(item.count).toBe('');
});
test('a fresh draft (no count yet) stays blank while only one weight is entered', () => {
const item = {total_weight: '', unit_weight: '5', count: ''};
applyComputedCounts(item, configs, 'unit_weight');
expect(item.count).toBe('');
});
});

View file

@ -54,6 +54,35 @@ export function convert(value, fromUnit, toUnitName) {
}
}
/**
* Estimate how many items a lot contains from a total weight and a single item's weight:
* count = round(total / unit), converting `total` into the unit-weight's unit first (so e.g. a 2 kg total and a
* 5 g unit weight give 400). The result is rounded to a whole number — you can't have 403.7 nails. A total below
* one item (or zero) gives 0, not null, so a corrected-down weight doesn't leave a stale count behind.
* Returns null when a weight is blank, the unit weight is non-positive, or the units are incompatible.
*/
export function countByWeight(total, totalUnit, unitWeight, unitWeightUnit) {
const each = Number(unitWeight);
if (!(each > 0) || total === '' || total === null || total === undefined) {
return null;
}
let totalInEachUnit;
if (totalUnit && unitWeightUnit && totalUnit !== unitWeightUnit) {
totalInEachUnit = convert(total, totalUnit, unitWeightUnit); // null if incompatible
} else if ((totalUnit || '') === (unitWeightUnit || '')) {
// Same unit (or both unitless) — divide the raw magnitudes.
const t = Number(total);
totalInEachUnit = Number.isFinite(t) ? t : null;
} else {
// One side has a unit and the other doesn't — can't safely divide, so refuse to guess.
return null;
}
if (totalInEachUnit === null || !Number.isFinite(totalInEachUnit) || totalInEachUnit < 0) {
return null;
}
return Math.round(totalInEachUnit / each); // 0 for a sub-unit (or zero) total
}
function compact(unitValue) {
// unitValue is a mathjs Unit. On a compatible ladder, pick the largest unit where the value is still >= 1.
for (const ladder of COMPACTION_LADDERS) {

View file

@ -1,4 +1,36 @@
import {convert, formatTotals, sumQuantities} from "./units";
import {convert, countByWeight, formatTotals, sumQuantities} from "./units";
describe('countByWeight', () => {
test('divides total by unit weight and rounds to a whole number', () => {
expect(countByWeight('1000', 'g', '5', 'g')).toBe(200);
// 403.7 nails -> 404 (rounded to a whole number).
expect(countByWeight('1009.25', 'g', '2.5', 'g')).toBe(404);
});
test('converts mismatched units before dividing', () => {
expect(countByWeight('2', 'kg', '5', 'g')).toBe(400); // 2 kg / 5 g
expect(countByWeight('1', 'lb', '1', 'oz')).toBe(16);
});
test('a sub-unit or zero total yields a count of 0 (not null)', () => {
expect(countByWeight('1', 'g', '5', 'g')).toBe(0); // round(0.2) = 0
expect(countByWeight('0', 'g', '5', 'g')).toBe(0);
});
test('returns null when a weight is missing, zero, or incompatible', () => {
expect(countByWeight('', 'g', '5', 'g')).toBeNull();
expect(countByWeight('1000', 'g', '', 'g')).toBeNull();
expect(countByWeight('1000', 'g', '0', 'g')).toBeNull();
expect(countByWeight('1000', 'lb', '5', 'gallon')).toBeNull(); // incompatible dimensions
});
test('refuses to divide when only one side has a unit', () => {
expect(countByWeight('2', '', '1', 'oz')).toBeNull();
expect(countByWeight('2', 'oz', '1', '')).toBeNull();
// Both unitless divides the raw magnitudes.
expect(countByWeight('100', '', '4', '')).toBe(25);
});
});
describe('convert', () => {
test('converts within mass', () => {

View file

@ -81,6 +81,26 @@ def test_save_inventory_fields(food_inventory_factory, test_inventory_configs):
assert [f['order'] for f in saved['fields']] == [0, 1]
def test_save_inventory_fields_preserve_compute_metadata(food_inventory_factory, test_inventory_configs):
"""A field's extra `compute` metadata (e.g. count-by-weight) must round-trip through save and re-import."""
config = test_inventory_configs
slug = food_inventory_factory()
compute = {'kind': 'count_by_weight', 'total': 'total_weight', 'unit': 'unit_weight'}
saved = config.save_inventory(slug, dict(fields=[
dict(key='name', label='Name', type='text'),
dict(key='unit_weight', label='Unit Weight', type='quantity', unit='g'),
dict(key='total_weight', label='Total Weight', type='quantity', unit='g'),
dict(key='count', label='Count', type='number', compute=compute),
]))
assert next(f for f in saved['fields'] if f['key'] == 'count')['compute'] == compute
# A fresh config instance re-reads from disk; the metadata persists.
fresh = inventory_common.InventoriesConfig()
fresh.initialize()
restored = fresh.get_inventory(slug)
assert next(f for f in restored['fields'] if f['key'] == 'count')['compute'] == compute
def test_save_inventory_rename_keeps_slug(food_inventory_factory, test_inventory_configs):
config = test_inventory_configs
slug = food_inventory_factory(name='Food Storage')