mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
+1
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user