Skip to content

Commit be84d4a

Browse files
committed
v2.21.1 — urgent: stop depth racks and twin pairs being unranked on launch
1 parent 09d2b9b commit be84d4a

9 files changed

Lines changed: 129 additions & 58 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ Versions follow [Semantic Versioning](https://semver.org/).
55

66
---
77

8+
## v2.21.1 — 2026-08-24
9+
10+
### Fixed
11+
12+
- **Every spool in a back row was unranked on launch.** A two-deep rack came back with only its front row filled and the rest sitting in "Not stored" — written straight to Firestore, so the arrangement was gone for good. `healDuplicateSlots` finds two spools sharing one slot and evicts the extras, and its slot key was `rackId | level | position` — **without the depth**. A spool behind therefore shared a key with the spool in front of it, so a full two-deep column read as a pile of duplicates and everything but the front one was written back to `rack: null`. Caught in the app's own log: `[racks] self-heal: 57 spool(s) in already-occupied slots → unranked`. The depth is part of the key now: two rows deep is not a collision, it is the feature. It fired ~2 s after launch, behind the "What's New" window, so the damage only surfaced when that window was dismissed — which made it look like closing it was the cause — `renderer/inventory.js`.
13+
- **One half of a twin pair was unranked on launch, at random.** A twin pair is ONE physical spool wearing two chips, so both halves legitimately share a slot, and the heal groups them before it counts. But only one of the two documents carries `twin_tag_uid` — which is exactly why `_markTwinPairs` flags both sides rather than trusting the field — while `twinSpoolIdOf` only ever read that one direction. The pair therefore read as a legitimate couple or as two rivals depending on which half the heal visited first. The lookup now resolves both ways. This was never confined to the heal: **nine** call sites resolve a twin, including `assignSpoolToSlot`'s twin mirror, which reached only one of the two documents depending on the direction — the asymmetry predates rack depth and was merely surfaced by it — `renderer/inventory.js`.
14+
- **Two heuristics that write `rack: null` on real spools now refuse to run when they would clear a large share of the stock.** The duplicate-slot heal and the orphan-reference sweep both act on a guess — "you are on top of someone" / "your rack no longer exists" — and a guess that erases where a user put a quarter of their collection has misread something rather than found that much genuine mess. Both step aside and say so in the log instead of writing the mistake to every document they can reach. The orphan sweep additionally waits for a racks snapshot **confirmed by the server** (`!snap.metadata.fromCache`): a cold start is served from a local cache that may name fewer racks than exist, and acting on it would unrank everything living in the racks it forgot — `renderer/inventory.js`.
15+
16+
---
17+
818
## v2.21.0 — 2026-08-23
919

1020
### Added

‎FEATURES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Tiger Studio Manager — Feature Catalogue
22

3-
Tiger Studio Manager is the Electron desktop companion to the TigerTag ecosystem: a filament-inventory manager built around RFID/NFC-tagged spools ("TigerTag" chips), a fully-digital "TigerData" tier (and its catalogue-identified "TigerData+" rung), live 3D-printer integration across six brands, physical storage/rack management, a Firebase-backed social layer (friends, shareable wishlists, public profiles), and companion hardware (TD1S color sensor, TigerScale, TigerPOD dual-reader stand). This document catalogues every **shipped** feature, grouped by domain, current as of **v2.21.0**. Per-version release detail lives in `CHANGELOG.md`; forward-looking / in-progress work lives in `ROADMAP.md`.
3+
Tiger Studio Manager is the Electron desktop companion to the TigerTag ecosystem: a filament-inventory manager built around RFID/NFC-tagged spools ("TigerTag" chips), a fully-digital "TigerData" tier (and its catalogue-identified "TigerData+" rung), live 3D-printer integration across six brands, physical storage/rack management, a Firebase-backed social layer (friends, shareable wishlists, public profiles), and companion hardware (TD1S color sensor, TigerScale, TigerPOD dual-reader stand). This document catalogues every **shipped** feature, grouped by domain, current as of **v2.21.1**. Per-version release detail lives in `CHANGELOG.md`; forward-looking / in-progress work lives in `ROADMAP.md`.
44

55
---
66

‎WORKLOG.md‎

Lines changed: 4 additions & 42 deletions
Large diffs are not rendered by default.

‎data/release-notes/v2.21.1.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# v2.21.1
2+
3+
Emergency fix for anyone using the new **rack depth**. Please update.
4+
5+
## Fixed
6+
7+
- Fixed spools being thrown out of their racks on launch. If a rack held spools two or three rows deep, everything behind the front row came back as "not stored" a couple of seconds after the app opened — and because that happened behind the *What's New* window, it looked as though closing that window had caused it. Your arrangement was being erased for real, not just on screen.
8+
- Fixed one half of a twin-tagged spool (one spool, two chips) being thrown out of its slot at random on launch. This one predates rack depth; depth simply made it show.
9+
- Added a safety net behind both: the checks that tidy up spools sharing a slot, or pointing at a deleted rack, now refuse to act when they would empty a large part of your storage, and say so instead. A tidy-up is a handful of spools — never half your collection.
10+
11+
---
12+
13+
📖 **[Full changelog](https://github.com/TigerTag-Project/TigerTag-Studio-Manager/blob/main/CHANGELOG.md)** · ⬇️ Grab the installer for your OS from the **Assets** below (macOS `.dmg`, Windows `Setup .exe` or `portable .exe`, Linux `.AppImage`).

‎data/whatsnew.json‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8122,5 +8122,35 @@
81228122
}
81238123
}
81248124
]
8125+
},
8126+
"2.21.1": {
8127+
"date": "2026-08-24",
8128+
"items": [
8129+
{
8130+
"icon": "rack-grid",
8131+
"title": {
8132+
"en": "Your racks stay put now",
8133+
"fr": "Vos racks ne se vident plus",
8134+
"de": "Eure Regale bleiben jetzt, wie sie sind",
8135+
"es": "Tus estanterías ya no se vacían",
8136+
"it": "I tuoi scaffali restano come li hai lasciati",
8137+
"zh": "货架不会再自己清空了",
8138+
"pt": "Suas estantes não se esvaziam mais",
8139+
"pt-pt": "As suas estantes já não se esvaziam",
8140+
"pl": "Regały nie opróżniają się już same"
8141+
},
8142+
"body": {
8143+
"en": "Spools stored on a back row were being thrown out at startup. They stay exactly where you put them.",
8144+
"fr": "Les bobines rangées sur une rangée arrière se faisaient éjecter au démarrage. Elles restent exactement où vous les avez mises.",
8145+
"de": "Spulen in einer hinteren Reihe flogen beim Start wieder raus. Sie bleiben jetzt genau da, wo ihr sie hingestellt habt.",
8146+
"es": "Las bobinas guardadas en una fila trasera salían despedidas al arrancar. Se quedan justo donde las pusiste.",
8147+
"it": "Le bobine messe in una fila dietro venivano sbalzate fuori all'avvio. Ora restano esattamente dove le hai messe.",
8148+
"zh": "放在后排的线材以前一开机就被踢出货架。现在它们会待在你放的位置。",
8149+
"pt": "Bobinas guardadas numa fila de trás eram expulsas ao iniciar. Agora ficam exatamente onde você as colocou.",
8150+
"pt-pt": "As bobinas guardadas numa fila de trás eram expulsas ao arrancar. Agora ficam exatamente onde as colocou.",
8151+
"pl": "Szpule odłożone w tylnym rzędzie wylatywały przy starcie. Zostają dokładnie tam, gdzie je postawiłeś."
8152+
}
8153+
}
8154+
]
81258155
}
81268156
}

‎llms.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ It is neutral and agnostic by design: TigerTag takes no side between filament br
1010
distributors. The protocol is open and anyone can build a different ecosystem on it; nothing in this repo is a
1111
prerequisite for doing so.
1212

13-
Current version: **2.21.0** — macOS (Intel + Apple Silicon, signed & notarized), Windows, Linux.
13+
Current version: **2.21.1** — macOS (Intel + Apple Silicon, signed & notarized), Windows, Linux.
1414

1515
## Architecture
1616

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tigertag-inventory",
3-
"version": "2.21.0",
3+
"version": "2.21.1",
44
"description": "Tiger Studio Manager — desktop app",
55
"main": "main.js",
66
"engines": {

‎renderer/CODEMAP.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
351351

352352
---
353353

354-
## Display name + friend requests (L30300-31160)
354+
## Display name + friend requests (L30300-31350)
355355
| L | What | Anchors |
356356
|---|---|---|
357357
| 14796-14839 | **Display-name setup modal** (first-login pseudo picker) | `openDisplayNameSetup` |
@@ -386,7 +386,7 @@ Per-brand scan/manual flows live in `printers/<brand>/add-flow.js`; `inventory.j
386386

387387
---
388388

389-
## Electron RFID integration (L627-33100)
389+
## Electron RFID integration (L627-33400)
390390
| L | What | Anchors |
391391
|---|---|---|
392392
| 29714-29801 | Reader indicator (topbar), reader connect/disconnect, card present/removed badge | `renderRfidReaderBadges` |

‎renderer/inventory.js‎

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21272,6 +21272,11 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2127221272
// racks bleeding back in.
2127321273
if (state.friendView) return;
2127421274
state.racksLoading = false; // first (or any) snapshot arrived → drop the spinner
21275+
/* Cached or authoritative? A cold start serves the local cache first,
21276+
and a cache that is stale or partial names fewer racks than really
21277+
exist. Anything DESTRUCTIVE keyed on "this rack does not exist" has
21278+
to wait for the server's word — see the orphan sweep. */
21279+
if (!snap.metadata.fromCache) _racksFromServer = true;
2127521280
const racks = snap.docs.map(d => ({ id: d.id, ...d.data() }));
2127621281
racks.sort((a, b) => {
2127721282
const oa = a.order ?? 999, ob = b.order ?? 999;
@@ -21293,8 +21298,12 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2129321298
scheduleStudioStateRecord(); // re-arm deferred telemetry (rack counts changed)
2129421299
}, err => { state.racksLoading = false; console.warn("[racks]", err.code, err.message); renderRacksList(); });
2129521300
}
21301+
// True once a racks snapshot has come from the SERVER for the active account.
21302+
// Reset on unsubscribe, so an account switch starts from cache-only again.
21303+
let _racksFromServer = false;
2129621304
function unsubscribeRacks() {
2129721305
if (state.unsubRacks) { state.unsubRacks(); state.unsubRacks = null; }
21306+
_racksFromServer = false;
2129821307
_racksLoadedFor = null; // racks no longer loaded → re-arm the automation barrier
2129921308
}
2130021309

@@ -26057,13 +26066,29 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2605726066
// `twinUid`. Storage location must mirror to BOTH docs so a scan of
2605826067
// either tag returns the correct rack/level/position. This helper
2605926068
// returns the twin's spoolId or null when there's no twin.
26069+
/* A twin pair is ONE physical spool wearing two chips, so the link has to
26070+
read the same from either half. Only one of the two docs usually carries
26071+
`twin_tag_uid` — which is why `_markTwinPairs` flags both sides rather than
26072+
trusting the field — so a one-way lookup answered for one half and null for
26073+
the other. Everything that acts on a pair inherited that asymmetry: writing
26074+
a slot reached only one doc, and the duplicate-slot heal split a pair into
26075+
two "units" sharing a slot and unranked one of them, depending purely on
26076+
which half it happened to visit first. Both directions now. */
2606026077
function twinSpoolIdOf(row) {
26061-
if (!row || !row.twinUid) return null;
26062-
const twin = state.rows.find(r =>
26063-
r.spoolId !== row.spoolId &&
26064-
(String(r.uid) === String(row.twinUid) || String(r.spoolId) === String(row.twinUid))
26078+
if (!row) return null;
26079+
if (row.twinUid) {
26080+
const fwd = state.rows.find(r =>
26081+
r.spoolId !== row.spoolId &&
26082+
(String(r.uid) === String(row.twinUid) || String(r.spoolId) === String(row.twinUid))
26083+
);
26084+
if (fwd) return fwd.spoolId;
26085+
}
26086+
// Nothing on this doc — look for the half that points AT it.
26087+
const back = state.rows.find(r =>
26088+
r.spoolId !== row.spoolId && r.twinUid &&
26089+
(String(r.twinUid) === String(row.uid) || String(r.twinUid) === String(row.spoolId))
2606526090
);
26066-
return twin ? twin.spoolId : null;
26091+
return back ? back.spoolId : null;
2606726092
}
2606826093

2606926094
// Assign / move / unassign a spool to a slot. Performs a swap if the target
@@ -26488,7 +26513,12 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2648826513
const bySlot = new Map();
2648926514
for (const r of state.rows) {
2649026515
if (r.deleted || !r.rackId || r.rackLevel == null || r.rackPos == null) continue;
26491-
const key = `${r.rackId}|${r.rackLevel}|${r.rackPos}`;
26516+
/* THE DEPTH IS PART OF THE SLOT. Without it every spool in a back row
26517+
shared a key with the one in front of it, so this heal read a full
26518+
two-deep column as a pile of duplicates and unranked all but the front
26519+
one — 57 spools in one launch. A shelf holds one spool per (level,
26520+
position, DEPTH); two rows deep is not a collision, it is the feature. */
26521+
const key = `${r.rackId}|${r.rackLevel}|${r.rackPos}|${r.rackDepth ?? 0}`;
2649226522
let arr = bySlot.get(key); if (!arr) bySlot.set(key, arr = []);
2649326523
arr.push(r);
2649426524
}
@@ -26510,6 +26540,17 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2651026540
for (let i = 1; i < units.length; i++) for (const r of units[i]) evictIds.push(r.spoolId);
2651126541
}
2651226542
if (!evictIds.length) return;
26543+
/* Same guard rail as the orphan sweep: this writes `rack: null` on real
26544+
spools from a heuristic, and a heuristic that wants to unrack a large
26545+
share of the stock has misread something rather than found that much
26546+
genuine duplication. It steps aside and says so instead of writing the
26547+
mistake to every document it can reach. */
26548+
const racked = state.rows.filter(r => !r.deleted && r.rackId).length;
26549+
if (racked > 0 && evictIds.length > racked * 0.25) {
26550+
console.warn(`[racks] self-heal REFUSED — it wanted to unrank ${evictIds.length} of ${racked} racked spools. `
26551+
+ `That is not duplication; nothing was changed.`);
26552+
return;
26553+
}
2651326554
console.warn(`[racks] self-heal: ${evictIds.length} spool(s) in already-occupied slots → unranked:`, evictIds);
2651426555
try {
2651526556
const col = fbDb(uid).collection("users").doc(uid).collection("inventory");
@@ -28106,11 +28147,26 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2810628147
// Guarded so it runs at most once per renderRackView call AND not at all
2810728148
// while inventory or racks are still loading (to avoid clearing valid
2810828149
// rackIds before the rack doc has arrived).
28109-
if (!state.invLoading && state.racks.length > 0 && !_orphanCleanupInFlight) {
28110-
const orphans = state.rows.filter(x =>
28111-
!x.deleted && x.rackId && !racksById.has(x.rackId)
28112-
);
28113-
if (orphans.length > 0) {
28150+
/* This sweep WRITES `rack: null` on real spools, from a guess — "your rack
28151+
is gone, so your slot is meaningless". A guess that erases where a user
28152+
put every one of their spools has to be held to a much higher bar than a
28153+
render, so it now needs all three:
28154+
• the inventory settled AND the racks list confirmed BY THE SERVER —
28155+
a cold start serves a cache that may name fewer racks than exist, and
28156+
acting on it unracks everything living in the racks it forgot;
28157+
• something to clean — and something PLAUSIBLE: a genuine leftover is a
28158+
handful of spools pointing at a rack that was deleted. Half the stock
28159+
is not a leftover, it is a bug, and the sweep steps aside and says so
28160+
rather than writing the mistake to every document it can reach. */
28161+
const ORPHAN_SWEEP_MAX_SHARE = 0.25;
28162+
if (!state.invLoading && _racksFromServer && state.racks.length > 0 && !_orphanCleanupInFlight) {
28163+
const racked = state.rows.filter(x => !x.deleted && x.rackId);
28164+
const orphans = racked.filter(x => !racksById.has(x.rackId));
28165+
if (orphans.length > 0 && orphans.length > racked.length * ORPHAN_SWEEP_MAX_SHARE) {
28166+
console.warn(`[rack-cleanup] REFUSED — ${orphans.length} of ${racked.length} racked spools point at a rack `
28167+
+ `not in the ${state.racks.length} we hold. That is not a leftover; nothing was cleared.`);
28168+
} else if (orphans.length > 0) {
28169+
console.log(`[rack-cleanup] clearing ${orphans.length} of ${racked.length} racked spool(s)`);
2811428170
_orphanCleanupInFlight = true;
2811528171
_cleanupOrphanRackRefs(orphans).finally(() => { _orphanCleanupInFlight = false; });
2811628172
}

0 commit comments

Comments
 (0)