fix(inventory): show group totals on collapsed grouped rows (issue #1368)

With "Group similar" enabled, the collapsed group row showed a single
  member's values — a group of five 1 kg spools displayed 1000 g instead
  of 5 kg (#1368).

  The group header now aggregates across all members: the table view's
  Label / Net / Gross / Used / Remaining columns and the grid card's
  weight figure show group totals. Identity columns (Material, Brand,
  Colour) and the Cost/kg rate stay per-spool-correct since they are the
  group key; per-spool-only fields with no meaningful total (dates,
  location, note, tag ID) keep the representative member's value. The
  expanded per-spool rows are unchanged.

  New aggregateGroupSpool helper with 4 unit tests; the table group
  component's header prop is renamed representative -> headerSpool.
  Frontend-only — all data was already in the spool list.
This commit is contained in:
maziggy
2026-05-21 14:00:43 +02:00
parent e738645b0d
commit 7aad3fb395
6 changed files with 144 additions and 58 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Connection Diagnostic — self-service triage for "printer won't connect / won't print"** — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (`backend/app/services/printer_diagnostic.py`) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via `GET /printers/{id}/diagnostic` (saved printer) and `POST /printers/diagnostic` (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub `config.yml` troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green.
### Changed
- **Slice modal: cross-printer 3MFs now re-slice transparently, banner removed, modal fully i18n'd** — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound `Trent900.3mf` sliced via the X1C bundle (`POST /slice` with `bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle`) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — `printer_model` overridden to `Bambu Lab X1 Carbon`, `printable_area` to 256×256 (X1C bed, not H2D's 350×320), `printable_height` 250 (vs 325), `bed_exclude_area` populated with X1C's 18×28 corner zone, `nozzle_diameter` single 0.4 (vs H2D's dual `0.4,0.4`), and the full X1C `machine_start_gcode` sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. **Behavioural change**: dropped `!printerMismatch` from the SliceModal `isReady` predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. **Dead-code removal (same drop)**: with no banner, the `source_printer_model` field on the `/library/files/{id}/plates` and `/archives/{id}/plates` responses had zero consumers; the `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` (which opened the 3MF zip and read `Metadata/project_settings.config` on every plate request) had zero callers. Removed both response keys, both backend extractions, both `threemf_tools` imports, the helper itself, its 6 unit tests, the `source_printer_model` field from `frontend/src/types/plates.ts` (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. **i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]])**: every t() callsite in SliceModal.tsx had an inline English `defaultValue:` or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (`slice.bundle`, `slice.bundleNone`, `slice.bundleAllRequired`) that had **no** corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The `slice.printerMismatch` key was removed from all 8 locales (banner is gone). **Why this matters**: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. **Tests**: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch).
- **Filament inventory: grouped rows now show group totals (#1368, requested by a user)** — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New `aggregateGroupSpool` helper in `frontend/src/utils/inventoryGrouping.ts` with 4 unit tests. Frontend-only — all data was already in the spool list. — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound `Trent900.3mf` sliced via the X1C bundle (`POST /slice` with `bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle`) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — `printer_model` overridden to `Bambu Lab X1 Carbon`, `printable_area` to 256×256 (X1C bed, not H2D's 350×320), `printable_height` 250 (vs 325), `bed_exclude_area` populated with X1C's 18×28 corner zone, `nozzle_diameter` single 0.4 (vs H2D's dual `0.4,0.4`), and the full X1C `machine_start_gcode` sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. **Behavioural change**: dropped `!printerMismatch` from the SliceModal `isReady` predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. **Dead-code removal (same drop)**: with no banner, the `source_printer_model` field on the `/library/files/{id}/plates` and `/archives/{id}/plates` responses had zero consumers; the `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` (which opened the 3MF zip and read `Metadata/project_settings.config` on every plate request) had zero callers. Removed both response keys, both backend extractions, both `threemf_tools` imports, the helper itself, its 6 unit tests, the `source_printer_model` field from `frontend/src/types/plates.ts` (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. **i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]])**: every t() callsite in SliceModal.tsx had an inline English `defaultValue:` or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (`slice.bundle`, `slice.bundleNone`, `slice.bundleAllRequired`) that had **no** corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The `slice.printerMismatch` key was removed from all 8 locales (banner is gone). **Why this matters**: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. **Tests**: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch).
### Security
- **idna: bump to `>=3.15` to clear CVE-2026-45409 (ReDoS in `idna.encode()` with crafted Unicode payloads, e.g. `"٠" * N` or `"・" * N + "漢"`)** — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit `idna>=3.15` floor in `requirements.txt` between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via `pip-audit` clean post-upgrade.
@@ -10,6 +10,7 @@
import { describe, it, expect } from 'vitest';
import type { InventorySpool, SpoolAssignment } from '../../api/client';
import { aggregateGroupSpool } from '../../utils/inventoryGrouping';
// Replicate the grouping key function from InventoryPage (not exported).
// Must stay in lockstep with InventoryPage.tsx::spoolGroupKey — extra_colors
@@ -290,3 +291,50 @@ describe('computeDisplayItems', () => {
expect(items).toHaveLength(0);
});
});
describe('aggregateGroupSpool (#1368)', () => {
it('sums label_weight, weight_used and core_weight across members', () => {
const agg = aggregateGroupSpool([
makeSpool({ id: 1, label_weight: 1000, weight_used: 200, core_weight: 250 }),
makeSpool({ id: 2, label_weight: 1000, weight_used: 50, core_weight: 250 }),
makeSpool({ id: 3, label_weight: 1000, weight_used: 0, core_weight: 250 }),
]);
expect(agg.label_weight).toBe(3000);
expect(agg.weight_used).toBe(250);
expect(agg.core_weight).toBe(750);
});
it('aggregate remaining equals the sum of per-member remaining', () => {
const members = [
makeSpool({ id: 1, label_weight: 1000, weight_used: 200 }), // 800
makeSpool({ id: 2, label_weight: 1000, weight_used: 600 }), // 400
];
const agg = aggregateGroupSpool(members);
const perMember = members.reduce(
(sum, s) => sum + Math.max(0, s.label_weight - s.weight_used),
0,
);
expect(agg.label_weight - agg.weight_used).toBe(perMember);
expect(agg.label_weight - agg.weight_used).toBe(1200);
});
it('carries identity fields from the first member', () => {
const agg = aggregateGroupSpool([
makeSpool({ id: 7, material: 'PETG', brand: 'Bambu Lab', color_name: 'Blue', rgba: '0000FFFF' }),
makeSpool({ id: 8, material: 'PETG', brand: 'Bambu Lab', color_name: 'Blue', rgba: '0000FFFF' }),
]);
expect(agg.id).toBe(7);
expect(agg.material).toBe('PETG');
expect(agg.brand).toBe('Bambu Lab');
expect(agg.color_name).toBe('Blue');
});
it('returns a member equivalent for a single-element group', () => {
const agg = aggregateGroupSpool([
makeSpool({ id: 1, label_weight: 750, weight_used: 100, core_weight: 200 }),
]);
expect(agg.label_weight).toBe(750);
expect(agg.weight_used).toBe(100);
expect(agg.core_weight).toBe(200);
});
});
+23 -9
View File
@@ -25,6 +25,7 @@ import { getCurrencySymbol } from '../utils/currency';
import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
import { formatSlotLabel } from '../utils/amsHelpers';
import { filterSpoolsByQuery } from '../utils/inventorySearch';
import { aggregateGroupSpool } from '../utils/inventoryGrouping';
type ArchiveFilter = 'active' | 'archived';
type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
@@ -1584,6 +1585,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
{pagedItems.map((item) => {
if (item.type === 'group') {
const { key, spools: groupSpools, representative: rep } = item;
// Total remaining filament across the group (#1368) — the
// headline number for the collapsed card, vs one member's.
const groupRemaining = groupSpools.reduce(
(sum, s) => sum + Math.max(0, s.label_weight - s.weight_used),
0,
);
const groupBannerStyle = buildFilamentBackground({
rgba: rep.rgba,
extraColors: rep.extra_colors,
@@ -1613,7 +1620,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">{formatWeight(rep.label_weight)}</span>
<span className="text-sm text-bambu-gray" title={t('inventory.remaining')}>
{formatWeight(groupRemaining)}
</span>
<span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
{t('inventory.groupedSpools', { count: groupSpools.length })}
</span>
@@ -1717,15 +1726,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
<tbody>
{pagedItems.map((item) => {
if (item.type === 'group') {
const { key, spools: groupSpools, representative: rep } = item;
const { key, spools: groupSpools } = item;
const isExpanded = expandedGroups.has(key);
const remaining = Math.max(0, rep.label_weight - rep.weight_used);
const pct = rep.label_weight > 0 ? (remaining / rep.label_weight) * 100 : 0;
// Header row shows group totals (#1368): an aggregate
// spool plus remaining / pct summed across all members.
const headerSpool = aggregateGroupSpool(groupSpools);
const remaining = Math.max(0, headerSpool.label_weight - headerSpool.weight_used);
const pct = headerSpool.label_weight > 0 ? (remaining / headerSpool.label_weight) * 100 : 0;
return (
<SpoolTableGroup
key={`group-${key}`}
spools={groupSpools}
representative={rep}
headerSpool={headerSpool}
remaining={remaining}
pct={pct}
isExpanded={isExpanded}
@@ -2180,12 +2192,14 @@ function SpoolTableRow({
/* Grouped spool rows for table view */
function SpoolTableGroup({
spools, representative, remaining, pct, isExpanded, onToggle,
spools, headerSpool, remaining, pct, isExpanded, onToggle,
onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetUsage,
visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
}: {
spools: InventorySpool[];
representative: InventorySpool;
// Aggregate of all members (summed quantities, shared identity) — rendered
// in the collapsed header row so it shows group totals (#1368).
headerSpool: InventorySpool;
remaining: number;
pct: number;
isExpanded: boolean;
@@ -2216,14 +2230,14 @@ function SpoolTableGroup({
{idx === 0 ? (
<div className="flex items-center gap-2">
<ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
{columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
{columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
</div>
) : colId === 'id' ? (
<span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
{t('inventory.groupedSpools', { count: spools.length })}
</span>
) : (
columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })
columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })
)}
</td>
))}
+24
View File
@@ -0,0 +1,24 @@
import type { InventorySpool } from '../api/client';
// Synthesize a spool-shaped object for a group's collapsed header row: the
// quantity fields (label_weight, weight_used, core_weight) are summed across
// members so the header shows group totals (#1368), while identity fields are
// carried from the first member — all members share them, they are the group
// key. The expanded per-spool rows keep using the real per-member spools.
export function aggregateGroupSpool(spools: InventorySpool[]): InventorySpool {
const base = spools[0];
let labelWeight = 0;
let weightUsed = 0;
let coreWeight = 0;
for (const s of spools) {
labelWeight += s.label_weight;
weightUsed += s.weight_used;
coreWeight += s.core_weight;
}
return {
...base,
label_weight: labelWeight,
weight_used: weightUsed,
core_weight: coreWeight,
};
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-D23KVJz4.js"></script>
<script type="module" crossorigin src="/assets/index-DG9hmpJq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QwBJJHPy.css">
</head>
<body>