Skip to content

Commit 17bfbd2

Browse files
committed
feat: storage units on the machine, board widgets, group binding
Storage - A machine's filament storage lives on the machine: a `units` map on its own document, one entry per physical unit (an AMS / CFS / ACE box, a holder pair, the external arm), each with its shape, a renamable label, a position on each board, and slots holding what the user assigned plus what the machine last reported, stamped with `seenAt`. - Every brand describes its units from its own PROTOCOL.md, behind `getUnits`. The seeder never overwrites what the user owns — label, shape, positions, assignments — and a unit that stops being reported is marked absent rather than deleted: unplugging an Elegoo Canvas hub must not discard what was in it. - The bays are drawn at exactly a rack slot's size and scale, in four states: unknown, configured-but-not-loaded, coloured-without-a-material, loaded. - Shown from the record at startup, dimmed until the machine's link comes up, so last-known contents can never be mistaken for a live reading. Board - Units are grouped per machine by default and split on demand; the two arrangements are stored apart, so flipping between them discards neither. - A temperature widget, rendering each brand's own card rather than a seventh version of it. Widgets switch on and off from the machine's kebab, and a widget switched on takes the first free place down its machine's column. - Everything a machine puts on the board is bound to it from the start, so it travels with it; the binding is releasable from a right-click anywhere, including inside a group's own ring. - One width for every card, derived from what a four-slot unit measures. Robustness - Nothing rebuilds or re-places the board while something is held or a menu is open — and no deferral can outlive two seconds, because a guard that freezes the screen is worse than the flicker it avoids. - A position or a switch just written outranks the record until the server agrees, so a snapshot in flight cannot revert it. - One failing widget is skipped and logged instead of leaving the view blank.
1 parent 1995bc1 commit 17bfbd2

27 files changed

Lines changed: 40008 additions & 29547 deletions

‎WORKLOG.md‎

Lines changed: 27 additions & 3 deletions
Large diffs are not rendered by default.

‎assets/db/tigertag/id_catalog.json‎

Lines changed: 38307 additions & 29409 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"versions": 1763073059935, "types": 1783889853153, "brands": 1785883904753, "filament_diameters": 1777895560487, "filament_materials": 1781878743554, "aspects": 1785250220651, "measure_units": 1777896731691, "products": 1787325632316}
1+
{"versions": 1763073059935, "types": 1783889853153, "brands": 1785883904753, "filament_diameters": 1777895560487, "filament_materials": 1781878743554, "aspects": 1785250220651, "measure_units": 1777896731691, "products": 1787754171513}

‎assets/svg/icons/icon_unlink.svg‎

Lines changed: 8 additions & 0 deletions
Loading

‎docs/PRINTER-PLAN-GROUP-BRIEF.md‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Persistent card groups in the printer plan — implementation brief
2+
3+
**Status: IMPLEMENTED in v2.21.2.** Kept as the record of what was agreed and why. The word chosen
4+
was **cluster**; the data model is (b) — a cluster id on each member, beside the coordinates it binds
5+
(`planCluster` / `unitsPlanCluster` / `units.{id}.planPrintersCluster`), so no new collection and no
6+
rules change was needed, and deleting a printer takes its membership with it. Selection expansion
7+
lives in `_syncPlanSelection`, so every way of selecting inherits it; the drag itself never learned
8+
about clusters. Anchors: `clusterOf`, `clusterMembers`, `saveBoardCluster`, `_drawClusterHulls`,
9+
`wirePlanClusterMenu` — see `renderer/CODEMAP.md` → *Printers views*.
10+
11+
## Goal
12+
13+
In the **Printers view, plan (grid) mode**, let the user pick several cards, **right-click → Group**,
14+
and have that set stay bound together from then on: selecting any member selects the whole group, so
15+
dragging one moves them all — the way grouped objects behave in a drawing program. Right-click →
16+
Ungroup dissolves it.
17+
18+
Founder's wording, verbatim: *"le groupement persiste une fois le group des card fait. mais quand on
19+
sélectionne un groupe c'est pas persistant sauf si on fait clic droit 'grouped'"* — i.e. an ordinary
20+
multi-selection stays transient as today; only an explicit **Group** action makes it durable.
21+
22+
## Half of this already exists — do not rebuild it
23+
24+
The plan already behaves like a drawing app for a **transient** selection
25+
(`renderer/inventory.js`, plan drag block ~L23207-23285):
26+
27+
- `_planSel` — a `Set` of selected `boardId`s, with `_syncPlanSelection()` / `_clearPlanSelection()`.
28+
- **Shift+click** toggles a card in and out of the selection (`e.shiftKey`).
29+
- Dragging a **selected** card carries the rest as `followers`, each keeping its own offset.
30+
Dragging an **unselected** one clears the selection and moves it alone.
31+
- The code comment already states the intent: *"which is how every drawing program behaves"*.
32+
- `is-plan-selected` marks members; `_printerArrange` gates arrange mode; the plan has its own zoom.
33+
34+
So the work is **not** "make a selection move together" — that works. It is: **turn a selection into
35+
a durable group, and make selecting one member select the group.**
36+
37+
## ⚠️ Naming: "group" is already taken THREE times
38+
39+
Pick a different word before writing a single identifier, or this becomes unreadable:
40+
41+
| Existing meaning | Where |
42+
|---|---|
43+
| Identical spools auto-grouped in the inventory | `_spoolGroupKey`, `_openGroupPanel` |
44+
| User-made **Lists** of product identities | the Lists view |
45+
| **`GROUP_PREFIX`** — the units of ONE multi-unit printer shown as a single board (`unit: "*"` = "all of them, as one") | `_boardObj`, plan code |
46+
47+
That last one is the dangerous one: it lives in the very code this feature touches. A set of several
48+
DIFFERENT machines needs its own name — e.g. **cluster**, **bundle**, **set** — never `group` alone.
49+
50+
## Data model — where positions live today
51+
52+
A board is addressed by `boardId`, resolved by `_boardObj()`:
53+
54+
- plain `<printerKey>` → the whole printer
55+
- `UNIT_PREFIX + <printerKey>:<unit>` → one unit of a multi-unit machine
56+
- `GROUP_PREFIX + <printerKey>` → all its units as one board
57+
58+
Positions come from `boardPos()` → `printerPlanPos(p)` for a whole printer, or `planPrinters {x,y}`
59+
on the unit / `unitsPlan` for the grouped form. Persistence therefore already hangs off the printer
60+
documents, not a separate collection.
61+
62+
**Decision needed:** store a cluster as (a) a new per-account doc (e.g.
63+
`users/{uid}/printerClusters/{id}` holding `boardIds[]`), or (b) a `clusterId` field written on each
64+
member. (a) keeps membership atomic and is easier to dissolve; (b) avoids a new collection and a new
65+
security-rules block. Both need a rules change if (a) — see `CLAUDE.md` → *Firestore Security Rules*.
66+
67+
## Behaviour to build
68+
69+
1. **Right-click on a plan card** → context menu with **Group** (when 2+ boards are selected) /
70+
**Ungroup** (when the card is in a cluster). Note: the app has almost no precedent for a context
71+
menu — the only `contextmenu` listener today is on a rack slot (~L30246). The rack menu
72+
(`.rp-menu`, `.rp-menu-item`) is the closest existing component to reuse.
73+
2. **Selecting a member selects the whole cluster** — expand the selection in the pointerdown path
74+
before `followers` is computed, so the existing drag code carries the cluster with no change.
75+
3. **Visual**: a cluster must be legible at a glance (a shared outline / tint behind the members).
76+
Follow the app's own UI rules in `CLAUDE.md` (real icons, visible selected state, smooth motion).
77+
4. **Ungroup** clears the binding but leaves every card exactly where it stands.
78+
5. Deleting a printer must not leave a dangling member in a cluster.
79+
80+
## Conventions
81+
82+
`CLAUDE.md` applies: all logic in `inventory.js`, CSS in the matching `renderer/css/` file (plan and
83+
printer styles live in `40-printers.css`), **9-locale i18n via `npm run i18n:add`** for every new
84+
string, brand voice (register 3), CODEMAP kept in sync, validators green before proposing a commit,
85+
and **no commit without an explicit order**.

‎docs/firestore-schema.md‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ users/
193193
what was assigned in it.
194194
planPrinters map? — { x, y, z } on the Printers board
195195
planRacks map? — { x, y, z } on the Storage board
196+
planPrintersCluster string? — the cluster this unit's widget belongs to
197+
on the Printers board (see `planCluster` below)
196198
slots array — [{ index, label, hw, uids, seenAt, color, material,
197199
subType, vendor, … }]. `hw` is strictly what
198200
addresses the slot on the wire (Bambu amsId/trayId,
@@ -207,10 +209,42 @@ users/
207209
Vocabulary + rationale: docs/printer-storage-terms.md
208210
plan map? — where the machine's CARD sits on the printer
209211
board: { x, y, z } in board pixels.
210-
slotsPlan map? — where its FEED-SLOT widget sits on that same
211-
board: { x, y, z }. Separate from the storage
212-
board's coordinates, which live on the rack —
213-
two boards, two coordinate spaces.
212+
unitsPlan map? — where its units sit on that board WHEN GROUPED:
213+
{ x, y, z }. Kept even while split, so grouping
214+
again finds the group where it was left.
215+
planCluster string? — the cluster this machine's CARD belongs to on the
216+
printer board: several objects the user bound
217+
together, so selecting any one selects the whole
218+
set and dragging one moves them all. The binding
219+
lives on the MEMBERS, beside the coordinates it
220+
binds, never in a collection of its own — so
221+
deleting a machine takes its membership with it
222+
and a cluster can never point at a card that no
223+
longer exists. Absent = not bound to anything;
224+
a cluster left with a single member is no
225+
cluster. Deliberately not called a group: that
226+
word is already the inventory's spool grouping,
227+
the user's Lists, and `unitsPlan` below.
228+
unitsPlanCluster string? — same, for its units widget when they are grouped.
229+
widgets map? — which of the machine's widgets are on the
230+
board: { units, temp } → boolean. ABSENT
231+
MEANS SHOWN, so a machine nobody has touched
232+
puts everything up and only a deliberate
233+
choice is ever written. The switches live on
234+
the machine's ⋮ — a hidden widget has no menu
235+
of its own left to bring it back with.
236+
tempPlan map? — where its TEMPERATURE widget sits on that
237+
board: { x, y, z }. Not storage — the board
238+
addresses every kind through one
239+
`data-board-id`, so a further widget needs no
240+
new machinery, only a case.
241+
unitsSplit boolean? — the user chose to place each unit separately.
242+
Absent/false = grouped, which is the default
243+
because that is how most benches look. The two
244+
arrangements are stored apart (this position for
245+
the group, `units.{id}.planPrinters` for each
246+
unit), so flipping between them never discards
247+
the other.
214248
tags string[]? — user-defined free-form labels (Shopify-style), same editor as spool tags but a separate namespace (printer tags never mix with spool tags). Studio metadata only. Owner-write, no rule change needed (printers subtree has no field whitelist). Cross-app field — mobile ignores it until it implements printer tags
215249
updatedAt timestamp
216250
discovery map? — last mDNS/HTTP discovery snapshot (raw + derived)

‎renderer/CODEMAP.md‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,15 @@ Manual spool creation: full chip-schema editor with bottom-sheets. All helpers p
294294
| 9503-9625 | **Table view** — sortable columns, row click → sidecard | `_renderPrinterTable` |
295295
| 9626-9888 | **Cam wall view** — patch mode, card sizes, detached cam window serializer | `_renderPrinterCam`, `_patchCamWall`, `_serializeCamerasForDetach` |
296296
| 9889-10086 | Printer + cam-wall drag-drop reordering (writes `sortIndex`) | `wirePrinterDnd`, `wireCamWallDnd`, `persistPrinterSortIndices` |
297+
| 22885-23010 | **The board's objects** — one `data-board-id` addresses a machine (`brand:id`), one of its units (`unit:…`) or its units as one widget (`units:…`); position/z read + saved through the same three functions | `_boardObj`, `boardPos`, `boardZ`, `boardSave`, `saveUnitPlanPos`, `savePrinterPlanPos` |
298+
| 23011-23100 | **Clusters** — several board objects bound together for good. The id lives on the members beside the coordinates it binds (`planCluster` / `unitsPlanCluster` / `units.{id}.planPrintersCluster`), so no new collection, no rules block, and deleting a machine takes its membership with it | `_newClusterId`, `_printerBoardIds`, `clusterOf`, `clusterMembers`, `saveBoardCluster` |
299+
| 23149-23310 | **Plan layout** — places every object at its own coordinates, adopts orphans, compacts z to 1..N, sizes the board, then draws one outline per cluster | `layoutPrintersPlan`, `seedPrinterPlan`, `_drawClusterHulls` |
300+
| 23340-23600 | **Selection + drag** — a `Set` of board ids; selecting any member of a cluster expands to the whole of it in `_syncPlanSelection`, so the drag carries clusters without knowing they exist | `_syncPlanSelection`, `_expandPlanSelToClusters`, `_clearPlanSelection`, `wirePrinterMarquee`, `wirePrinterPlanDrag` |
301+
| 23600-23710 | **The board's right-click menu** — group / ungroup the selection; the kebab menu's own component, dropped from the cursor | `openPlanContextMenu`, `closePlanContextMenu`, `wirePlanClusterMenu` |
297302

298303
---
299304

300-
## Printer detail side panel (L22514-25830)
305+
## Printer detail side panel (L23860-26904)
301306
| L | What | Anchors |
302307
|---|---|---|
303308
| 10087-10649 | Open/close lifecycle (connect/disconnect per brand), conn button, refresh | `openPrinterDetail`, `closePrinterDetail`, `refreshOpenPrinterDetail` |
@@ -307,7 +312,7 @@ Manual spool creation: full chip-schema editor with bottom-sheets. All helpers p
307312

308313
---
309314

310-
## Add-printer flow (L25340-26180)
315+
## Add-printer flow (L26321-27444)
311316
Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.js` owns the shell.
312317

313318
| L | What | Anchors |
@@ -319,7 +324,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
319324

320325
---
321326

322-
## Racks CRUD + slots (L25560-28830)
327+
## Racks CRUD + slots (L27114-29825)
323328
| L | What | Anchors |
324329
|---|---|---|
325330
| 12392-12526 | Rack create / update / delete / empty + orphan ref cleanup | `createRack`, `updateRack`, `deleteRack`, `emptyRack` |
@@ -331,7 +336,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
331336

332337
---
333338

334-
## Storage view render + DnD (L28150-30640)
339+
## Storage view render + DnD (L29437-31600)
335340
| L | What | Anchors |
336341
|---|---|---|
337342
| 13246-13885 | **`renderRackView()`** — biggest function in the file: stats bar + filter chips, two-column layout, masonry, kebab menus, live search, read-only friend mode, rack reorder DnD | `renderRackView` |
@@ -341,7 +346,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
341346

342347
---
343348

344-
## Friend view (L30580-31360)
349+
## Friend view (L31146-32480)
345350
| L | What | Anchors |
346351
|---|---|---|
347352
| 14329-14410 | Friend inventory open/close (one-shot read, no live updates) | `openFriendInventory`, `closeFriendInventory` |
@@ -351,7 +356,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
351356

352357
---
353358

354-
## Display name + friend requests (L31360-32420)
359+
## Display name + friend requests (L31928-33550)
355360
| L | What | Anchors |
356361
|---|---|---|
357362
| 14796-14839 | **Display-name setup modal** (first-login pseudo picker) | `openDisplayNameSetup` |
@@ -360,23 +365,23 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
360365

361366
---
362367

363-
## Keys + profile sync (L32250-32420)
368+
## Keys + profile sync (L32785-33657)
364369
| L | What | Anchors |
365370
|---|---|---|
366371
| 14993-15037 | **`claimPublicKey(uid, oldKey)`** atomic transaction (10 retries) + regenerate + send friend request | `claimPublicKey`, `sendFriendRequest` |
367372
| 15062-15112 | Key generators (`XXX-XXX`, 40-char hex) + `userProfiles/{uid}` sync | `generatePublicKey`, `generatePrivateKey`, `syncUserProfile` |
368373

369374
---
370375

371-
## Custom avatar (L32600-33040)
376+
## Custom avatar (L33013-33941)
372377
| L | What | Anchors |
373378
|---|---|---|
374379
| 15600-15774 | File pick, image decode, alpha detection, resize to blob, upload, remove | `uploadCustomAvatar`, `removeCustomAvatar` |
375380
| 15775-15960 | **Discord-style cropper** — crop / zoom / rotate + cropped upload | `openAvatarCropper`, `uploadCroppedAvatar` |
376381

377382
---
378383

379-
## User doc sync + telemetry + bootstrap (L32120-34060)
384+
## User doc sync + telemetry + bootstrap (L33328-35029)
380385
| L | What | Anchors |
381386
|---|---|---|
382387
| 16253-16695 | **`syncUserDoc(uid)`** — displayName/roles/Debug/keys/isPublic + **client telemetry** (studio* fields + `telemetry/studio` aggregates, fire-and-forget) | `syncUserDoc`, `hydrateUserDocCache` |
@@ -386,7 +391,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
386391

387392
---
388393

389-
## Electron RFID integration (L627-34400)
394+
## Electron RFID integration (L34465-35351)
390395
| L | What | Anchors |
391396
|---|---|---|
392397
| 29714-29801 | Reader indicator (topbar), reader connect/disconnect, card present/removed badge | `renderRfidReaderBadges` |

‎renderer/css/30-racks.css‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,37 @@
200200
.rp-menu-item--danger .icon { background: var(--danger); }
201201
.rp-menu-item--danger:hover { background: var(--danger-soft); }
202202
.rp-menu-item--danger:hover .icon { background: var(--danger); }
203+
/* A heading inside a menu, for a group of switches. Quiet: it names a section,
204+
it is not something you can press. */
205+
.rp-menu-head {
206+
padding: 6px 12px 3px; font-size: 10px; font-weight: 700;
207+
letter-spacing: .04em; text-transform: uppercase; color: var(--muted);
208+
}
209+
/* A menu item that is a SWITCH, not an action: the label on the left, the
210+
switch on the right, the way a settings row reads everywhere else. A tick
211+
said "this happened"; a switch says "this is a thing you set", which is
212+
what these are. */
213+
.rp-menu-item--toggle { justify-content: space-between; gap: 14px; }
214+
.rp-menu-item--toggle .rp-menu-label { flex: 1; min-width: 0; }
215+
.rp-menu-switch {
216+
flex-shrink: 0; position: relative; width: 30px; height: 17px;
217+
border-radius: 999px; background: var(--surface-3);
218+
box-shadow: inset 0 0 0 1px var(--border);
219+
transition: background .15s, box-shadow .15s;
220+
}
221+
.rp-menu-switch::after {
222+
content: ""; position: absolute; top: 2px; left: 2px;
223+
width: 13px; height: 13px; border-radius: 50%;
224+
background: var(--muted);
225+
transition: transform .15s, background .15s;
226+
}
227+
.rp-menu-item--toggle.is-on .rp-menu-switch {
228+
background: var(--primary); box-shadow: inset 0 0 0 1px var(--primary);
229+
}
230+
.rp-menu-item--toggle.is-on .rp-menu-switch::after {
231+
transform: translateX(13px); background: #fff;
232+
}
233+
203234
.rp-menu-sep {
204235
height: 1px;
205236
background: var(--border);

0 commit comments

Comments
 (0)