diff --git a/app/src/components/inventory/FieldSchemaEditor.js b/app/src/components/inventory/FieldSchemaEditor.js
index 2a9f7a67..c48b3d20 100644
--- a/app/src/components/inventory/FieldSchemaEditor.js
+++ b/app/src/components/inventory/FieldSchemaEditor.js
@@ -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}) {
update(index, {options: e.target.value.split(',')})}/>}
+ {isCountByWeight(f) &&
+
+ Auto-counted: Total Weight ÷ Unit Weight
+ }
+
diff --git a/app/src/components/inventory/FieldSchemaEditor.test.js b/app/src/components/inventory/FieldSchemaEditor.test.js
index 98253ee7..4646b926 100644
--- a/app/src/components/inventory/FieldSchemaEditor.test.js
+++ b/app/src/components/inventory/FieldSchemaEditor.test.js
@@ -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();
+
+ 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();
diff --git a/app/src/components/inventory/InventoryTable.js b/app/src/components/inventory/InventoryTable.js
index 45a17b75..ae3802ee 100644
--- a/app/src/components/inventory/InventoryTable.js
+++ b/app/src/components/inventory/InventoryTable.js
@@ -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));
diff --git a/app/src/components/inventory/InventoryTable.test.js b/app/src/components/inventory/InventoryTable.test.js
index 77fe3a0c..27b91452 100644
--- a/app/src/components/inventory/InventoryTable.test.js
+++ b/app/src/components/inventory/InventoryTable.test.js
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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'},
diff --git a/app/src/components/inventory/computeFields.js b/app/src/components/inventory/computeFields.js
new file mode 100644
index 00000000..496f2639
--- /dev/null
+++ b/app/src/components/inventory/computeFields.js
@@ -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;
+}
diff --git a/app/src/components/inventory/computeFields.test.js b/app/src/components/inventory/computeFields.test.js
new file mode 100644
index 00000000..bece3cc8
--- /dev/null
+++ b/app/src/components/inventory/computeFields.test.js
@@ -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('');
+ });
+});
diff --git a/app/src/components/inventory/units.js b/app/src/components/inventory/units.js
index 357412aa..53859650 100644
--- a/app/src/components/inventory/units.js
+++ b/app/src/components/inventory/units.js
@@ -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) {
diff --git a/app/src/components/inventory/units.test.js b/app/src/components/inventory/units.test.js
index 8ff7f9a6..473a724d 100644
--- a/app/src/components/inventory/units.test.js
+++ b/app/src/components/inventory/units.test.js
@@ -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', () => {
diff --git a/modules/inventory/test/test_inventory.py b/modules/inventory/test/test_inventory.py
index b16388ca..a27a7d5d 100644
--- a/modules/inventory/test/test_inventory.py
+++ b/modules/inventory/test/test_inventory.py
@@ -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')