Make lot numbers on spool rows a Filter menu toggle

Always showing the lot was too bulky. It now sits behind "Show lot
numbers" in the Filter menu, next to the archived and no-spools
toggles, off by default. When on, every spool row in any grouping ends
its details line with the lot. Like showEmpty it lives in the URL (lots=1)
and is remembered per browser, since it only adds to what's shown.
This commit is contained in:
Donkie committed 2026-10-11 14:40:51 +02:00
1 parent dc18a3017f
commit abdfc73b8d
15 files changed
+197 -55

No files matched your search

+1
View File
@@ -62,6 +62,7 @@
"buttons.filter": "Filter",
"buttons.save": "Save",
"buttons.showArchived": "Show Archived",
"buttons.showLotNumbers": "Show lot numbers",
"buttons.showNoSpools": "Show filaments with no spools",
"buttons.unArchive": "Unarchive",
"changeFilament.adoptWeight": "Set this spool’s full weight to {{full}}",
+1
View File
@@ -22,6 +22,7 @@ function state(over: Partial<LibraryState> = {}): LibraryState {
filters: [],
showArchived: false,
showEmpty: true,
showLotNumbers: false,
page: 1,
pageSize: 20,
...over
@@ -138,7 +138,7 @@
{/each}
{:else}
{#each flatVMs as vm (vm.spool.id)}
<SpoolRow {vm} showSwatch indent={14} />
<SpoolRow showLotNumbers={libraryState.showLotNumbers} {vm} showSwatch indent={14} />
{/each}
{/if}
@@ -182,15 +182,33 @@
/>
{#if !collapsed && !noSpools}
{#each inUse as vm (vm.spool.id)}
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
<SpoolRow
showLotNumbers={libraryState.showLotNumbers}
{vm}
{showSwatch}
indent={26}
context={group.field}
/>
{/each}
{#each unusedByFilament as unused (unused[0].spool.id)}
{#if unused.length === 1}
<!-- A lone unused spool gains nothing from a collapsing header — it just
adds a click and a row — so render it inline like the used spools. -->
<SpoolRow vm={unused[0]} {showSwatch} indent={26} context={group.field} />
<SpoolRow
showLotNumbers={libraryState.showLotNumbers}
vm={unused[0]}
{showSwatch}
indent={26}
context={group.field}
/>
{:else}
<UnusedRow {unused} {showSwatch} indent={26} context={group.field} />
<UnusedRow
showLotNumbers={libraryState.showLotNumbers}
{unused}
{showSwatch}
indent={26}
context={group.field}
/>
{/if}
{/each}
{#if moreCount > 0}
@@ -366,6 +366,9 @@
let noSpoolsMatches = $derived(
libraryState.group === 'filament' && matchesTerms(m['buttons.showNoSpools'](), searchTerms(menuQuery))
);
let lotNumbersMatches = $derived(
libraryState.view === 'spools' && matchesTerms(m['buttons.showLotNumbers'](), searchTerms(menuQuery))
);
// An option's secondary text is on screen, so it has to be searchable: typing
// "petg" against a list that visibly says PETG must not come back empty.
let visibleOptions = $derived(
@@ -524,7 +527,28 @@
<span class="mi-label">{m['buttons.showNoSpools']()}</span>
</button>
{/if}
{#if !archivedMatches && !noSpoolsMatches && !visibleCategories.length}
{#if lotNumbersMatches}
{#if visibleCategories.length && !archivedMatches && !noSpoolsMatches}<div
class="menu-sep"
></div>{/if}
<button
class="menu-item"
role="menuitemcheckbox"
aria-checked={libraryState.showLotNumbers}
onclick={() => {
params.setShowLotNumbers(!libraryState.showLotNumbers);
close();
}}
>
<span class="mi-check"
>{#if libraryState.showLotNumbers}<SquareCheck size={15} />{:else}<Square
size={15}
/>{/if}</span
>
<span class="mi-label">{m['buttons.showLotNumbers']()}</span>
</button>
{/if}
{#if !archivedMatches && !noSpoolsMatches && !lotNumbersMatches && !visibleCategories.length}
<div class="menu-item"><span class="mi-label mi-meta">{m['search.noResults']()}</span></div>
{/if}
{:else}
@@ -16,11 +16,12 @@
indent?: number;
/** Listing context — drops whatever the enclosing group already implies. */
context?: RowContext;
showLotNumbers?: boolean;
}
let { vm, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
let { vm, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
let identity = $derived(rowIdentity(vm, context));
let identity = $derived(rowIdentity(vm, context, showLotNumbers));
let selected = $derived(params.isSelected(page.url.searchParams, 'spool', String(vm.spool.id)));
</script>
@@ -14,9 +14,10 @@
indent?: number;
/** Listing context, forwarded to the expanded per-instance rows. */
context?: RowContext;
showLotNumbers?: boolean;
}
let { unused, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
let { unused, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
// Unused spools (never drawn from — used_weight 0) of one filament are
// collapsed into a single summary row. Clicking can't know *which* instance
@@ -109,7 +110,7 @@
{#each unused as vm (vm.spool.id)}
<!-- Divider-mode instances align with the sibling used rows (same indent)
so the divider reads as the header for the section beneath it. -->
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} />
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} {showLotNumbers} />
{/each}
</div>
{/if}
+19
View File
@@ -217,3 +217,22 @@ describe('catalog views', () => {
expect(selectHrefFromState(state, 'filament', '7')).toBe('?view=filaments&group=none&sel=filament%3A7');
});
});
describe('lot number visibility', () => {
it('parses off by default, opt-in on spools, and off in catalogs', async () => {
const { parseLibraryState, selectHrefFromState } = await import('./params');
expect(parseLibraryState(new URLSearchParams()).showLotNumbers).toBe(false);
const state = parseLibraryState(new URLSearchParams('lots=1'));
expect(state.showLotNumbers).toBe(true);
expect(selectHrefFromState(state, 'spool', '7')).toBe('?lots=1&sel=spool%3A7');
for (const view of ['filaments', 'manufacturers']) {
expect(parseLibraryState(new URLSearchParams(`view=${view}&lots=1`)).showLotNumbers).toBe(false);
}
});
it('restores the preference and lets an explicit URL win', async () => {
const stored = '{"group":"filament","sort":"last_used","asc":false,"lots":true}';
expect(await hrefFor(stored, '')).toBe('/?lots=1');
expect(await hrefFor(stored, '?lots=0')).toBeNull();
});
});
+18 -5
View File
@@ -50,6 +50,8 @@ export interface LibraryState {
showArchived: boolean;
/** Also list filaments you hold no spools of, when grouping by filament. */
showEmpty: boolean;
/** Append lot numbers to spool row secondary text. */
showLotNumbers: boolean;
page: number;
pageSize: number;
}
@@ -62,7 +64,7 @@ const VIEWS: LibraryView[] = ['spools', 'filaments', 'manufacturers'];
* holds. A URL naming none of them is the one that defers to the remembered
* view; naming any of them describes a view of its own, which is taken whole so
* a stored grouping never gets spliced onto a link's sort. */
const VIEW_PARAMS = ['view', 'group', 'sort', 'dir', 'empty'];
const VIEW_PARAMS = ['view', 'group', 'sort', 'dir', 'empty', 'lots'];
const DEFAULTS = {
group: 'filament' as GroupMode,
@@ -70,6 +72,7 @@ const DEFAULTS = {
sortAsc: false,
showArchived: false,
showEmpty: false,
showLotNumbers: false,
page: 1,
pageSize: 20
};
@@ -152,6 +155,7 @@ export function parseLibraryState(params: URLSearchParams): LibraryState {
filters: parseFilters(params.getAll('f')),
showArchived: params.get('arch') === '1',
showEmpty: params.get('empty') === '1',
showLotNumbers: params.get('lots') === '1',
page: parsePositiveInt(params.get('page'), DEFAULTS.page),
pageSize: parsePositiveInt(params.get('size'), DEFAULTS.pageSize)
};
@@ -164,7 +168,8 @@ export function parseLibraryState(params: URLSearchParams): LibraryState {
...common,
group: view === 'filaments' && group === 'none' ? 'none' : layout.group,
sortKey: sort && catalogSortField(view, sort) ? sort : layout.sortKey,
showEmpty: false
showEmpty: false,
showLotNumbers: false
};
}
@@ -210,6 +215,7 @@ function serializeState(s: LibraryState): string {
}
if (s.showArchived !== DEFAULTS.showArchived) p.set('arch', s.showArchived ? '1' : '0');
if (s.showEmpty !== DEFAULTS.showEmpty) p.set('empty', s.showEmpty ? '1' : '0');
if (s.showLotNumbers !== DEFAULTS.showLotNumbers) p.set('lots', s.showLotNumbers ? '1' : '0');
if (s.page !== DEFAULTS.page) p.set('page', String(s.page));
if (s.pageSize !== DEFAULTS.pageSize) p.set('size', String(s.pageSize));
if (s.selection) p.set('sel', `${s.selection.kind}:${s.selection.id}`);
@@ -251,7 +257,8 @@ export function rememberedViewHref(url: URL): string | null {
group === DEFAULTS.group &&
stored.sortKey === DEFAULTS.sortKey &&
stored.sortAsc === DEFAULTS.sortAsc &&
stored.showEmpty === DEFAULTS.showEmpty
stored.showEmpty === DEFAULTS.showEmpty &&
stored.showLotNumbers === DEFAULTS.showLotNumbers
) {
return null;
}
@@ -264,7 +271,8 @@ export function rememberedViewHref(url: URL): string | null {
group,
sortKey: stored.sortKey,
sortAsc: stored.sortAsc,
showEmpty: stored.showEmpty
showEmpty: stored.showEmpty,
showLotNumbers: stored.showLotNumbers
});
return `${url.pathname}?${qs}`;
}
@@ -288,7 +296,8 @@ function navigate(next: LibraryState, replace = false): void {
group: next.group,
sortKey: next.sortKey,
sortAsc: next.sortAsc,
showEmpty: next.showEmpty
showEmpty: next.showEmpty,
showLotNumbers: next.showLotNumbers
});
}
@@ -372,6 +381,10 @@ export function setShowEmpty(showEmpty: boolean): void {
navigate({ ...currentState(), showEmpty, page: DEFAULTS.page });
}
export function setShowLotNumbers(showLotNumbers: boolean): void {
navigate({ ...currentState(), showLotNumbers, page: DEFAULTS.page });
}
export function setShowArchived(showArchived: boolean): void {
navigate({ ...currentState(), showArchived, page: DEFAULTS.page });
}
+31 -5
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { parseStoredView } from './viewPrefs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseStoredView, rememberView } from './viewPrefs';
// The stored view is the only piece of Library state that doesn't come from the
// URL (#1036), so it's also the only piece that can arrive malformed — from an
@@ -13,7 +13,8 @@ describe('parseStoredView', () => {
group: 'location',
sortKey: 'name',
sortAsc: true,
showEmpty: false
showEmpty: false,
showLotNumbers: false
});
});
@@ -22,7 +23,8 @@ describe('parseStoredView', () => {
group: 'filament',
sortKey: 'name',
sortAsc: true,
showEmpty: true
showEmpty: true,
showLotNumbers: false
});
});
@@ -54,7 +56,31 @@ describe('parseStoredView', () => {
group: 'colour',
sortKey: 'name',
sortAsc: false,
showEmpty: false
showEmpty: false,
showLotNumbers: false
});
});
});
afterEach(() => vi.unstubAllGlobals());
it('round-trips the lot number preference through storage', () => {
let stored: string | null = null;
vi.stubGlobal('localStorage', {
getItem: () => stored,
setItem: (_key: string, value: string) => {
stored = value;
}
});
const view = { group: 'filament', sortKey: 'name', sortAsc: true, showEmpty: false, showLotNumbers: true };
rememberView(view);
expect(parseStoredView(stored)).toEqual(view);
rememberView({ ...view, showLotNumbers: false });
expect(parseStoredView(stored)?.showLotNumbers).toBe(false);
});
it('defaults lot numbers off for an old stored view with empty enabled', () => {
expect(parseStoredView('{"group":"filament","sort":"name","asc":true,"empty":true}')?.showLotNumbers).toBe(
false
);
});
+15 -12
View File
@@ -16,12 +16,12 @@
// cache, one URL per distinct view — depends on the address bar being the whole
// truth about what's on screen.
//
// Only grouping and sort are remembered, plus the one toggle that ADDS rows
// (showEmpty). Filters, search and archived-visibility hide spools, and a hidden
// filter silently restored days later reads as missing data rather than as a
// preference. Showing filaments you own no spools of cannot produce that
// confusion -- restoring it can only put more on screen, never less -- so it is
// remembered while the hiding toggles still are not.
// Only grouping and sort are remembered, plus toggles that ADD information
// (showEmpty adds rows, showLotNumbers adds text). Filters, search and archived-
// visibility hide spools, and a hidden filter silently restored days later reads
// as missing data rather than as a preference. Showing empty filaments or lot
// numbers cannot produce that confusion -- restoring them can only put more on
// screen, never less -- so they are remembered while the hiding toggles are not.
const KEY = 'spoolman-v2-library-view';
@@ -35,6 +35,7 @@ export interface StoredView {
sortKey: string;
sortAsc: boolean;
showEmpty: boolean;
showLotNumbers: boolean;
}
/** Rebuild a stored view from its JSON, or null if there isn't a usable one. */
@@ -43,15 +44,15 @@ export function parseStoredView(raw: string | null): StoredView | null {
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return null;
const { group, sort, asc, empty } = parsed as Record<string, unknown>;
const { group, sort, asc, empty, lots } = parsed as Record<string, unknown>;
if (typeof group !== 'string' || typeof sort !== 'string' || typeof asc !== 'boolean') {
return null;
}
// `empty` arrived after this key shipped, so an entry written by an older
// build simply has no such field. Treating that as "off" keeps those
// `empty` and `lots` arrived after this key shipped, so an entry written
// by an older build simply has no such fields. Treating that as "off" keeps those
// entries valid; rejecting them would throw away a grouping the user
// picked long ago the first time they loaded a new version.
return { group, sortKey: sort, sortAsc: asc, showEmpty: empty === true };
return { group, sortKey: sort, sortAsc: asc, showEmpty: empty === true, showLotNumbers: lots === true };
} catch {
// Corrupt entry: the shipped view is no worse than what a first-time
// visitor gets.
@@ -80,7 +81,8 @@ export function rememberView(view: StoredView): void {
prev.group === view.group &&
prev.sortKey === view.sortKey &&
prev.sortAsc === view.sortAsc &&
prev.showEmpty === view.showEmpty
prev.showEmpty === view.showEmpty &&
prev.showLotNumbers === view.showLotNumbers
) {
// Selecting a spool or turning a page navigates too; only the changes that
// actually move the view are worth a synchronous localStorage write.
@@ -109,7 +111,8 @@ function write(view: StoredView): void {
group: view.group,
sort: view.sortKey,
asc: view.sortAsc,
empty: view.showEmpty
empty: view.showEmpty,
lots: view.showLotNumbers
})
);
} catch {
+25 -15
View File
@@ -99,25 +99,35 @@ describe('rowIdentity', () => {
rightLabel: unused ? 'unused' : 'used 3 days ago'
}) as SpoolVM;
it('shows the trimmed lot alongside usage in filament groups', () => {
expect(rowIdentity(vm(' LOT-2026-0915-A7 '), 'filament')).toEqual({
title: '',
sub: 'used 3 days ago · Lot Nr LOT-2026-0915-A7'
const contexts = ['flat', 'filament', 'vendor', 'material', 'location'] as const;
const subs = ['PLA · 1.75 mm', 'used 3 days ago', 'PLA · 1.75 mm', '1.75 mm', 'PLA · 1.75 mm'];
it.each(contexts)('defaults to master text in %s rows', (context) => {
expect(rowIdentity(vm('LOT'), context)).toEqual({
title: context === 'filament' ? '' : context === 'vendor' ? 'Galaxy Black' : 'Prusament Galaxy Black',
sub: subs[contexts.indexOf(context)]
});
});
it('shows the lot for individual unused spools too', () => {
expect(rowIdentity(vm('24A-1187', true), 'filament').sub).toBe('unused · Lot Nr 24A-1187');
it.each(contexts)('appends the trimmed lot when enabled in %s rows', (context) => {
const identity = rowIdentity(vm(''), context);
expect(rowIdentity(vm(' LOT-2026-0915-A7 '), context, true)).toEqual({
...identity,
sub: `${identity.sub} · Lot Nr LOT-2026-0915-A7`
});
});
it.each(['', ' '])('keeps usage alone for a blank lot (%j)', (lot) => {
expect(rowIdentity(vm(lot), 'filament')).toEqual({ title: '', sub: 'used 3 days ago' });
});
it.each(['flat', 'vendor', 'material', 'location'] as const)(
'keeps the identity unchanged in %s rows',
(context) => {
expect(rowIdentity(vm('LOT-2026-0915-A7'), context)).toEqual(rowIdentity(vm(''), context));
it.each(contexts)('adds nothing for blank lots in %s rows', (context) => {
for (const lot of ['', ' ']) {
expect(rowIdentity(vm(lot), context, true)).toEqual(rowIdentity(vm(lot), context));
}
);
});
it('shows the lot for individual unused spools too', () => {
expect(rowIdentity(vm('24A-1187', true), 'filament', true).sub).toBe('unused · Lot Nr 24A-1187');
});
it('omits the separator when the existing secondary text is empty', () => {
expect(rowIdentity({ ...vm('LOT'), rightLabel: '' }, 'filament', true).sub).toBe('Lot Nr LOT');
});
});
+14 -8
View File
@@ -115,7 +115,16 @@ export interface RowIdentity {
* only causes truncation — so we drop it and surface the part that actually
* distinguishes this spool instead.
*/
export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
export function rowIdentity(vm: SpoolVM, ctx: RowContext, showLotNumbers = false): RowIdentity {
const identity = contextualIdentity(vm, ctx);
const lot = showLotNumbers ? vm.spool.lot.trim() : '';
if (lot) {
identity.sub = [identity.sub, `${m['spool.fields.lotNr']()} ${lot}`].filter(Boolean).join(' · ');
}
return identity;
}
function contextualIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
const { vendor, filament } = vm;
const dims = `${filament.material} · ${filament.diameter} mm`;
switch (ctx) {
@@ -142,8 +151,9 @@ export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
* how they've been used and which lot they came from — #id, fill, weight and
* location already sit in their own columns — so there's nothing to make a bold
* title out of. The usage status leads, since every row can say it; the lot
* number follows when there is one (#1173), and a long one is what the ellipsis
* cuts, with the full text on hover. The inspector has the rest.
* number joins only when the user turns it on and there is one (#1173). A long
* lot is what the ellipsis cuts, with the full text on hover. The inspector has
* the rest.
*
* The comment deliberately stays out of it. It reads like a label on the handful
* of spools whose owner treats it as one, but it's a free-form textarea: people
@@ -152,11 +162,7 @@ export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
* truncates the usage status — the one thing every row can say — to make room.
*/
function spoolIdentity(vm: SpoolVM): RowIdentity {
const lot = vm.spool.lot.trim();
return {
title: '',
sub: lot ? `${vm.rightLabel} · ${m['spool.fields.lotNr']()} ${lot}` : vm.rightLabel
};
return { title: '', sub: vm.rightLabel };
}
/**
+19 -1
View File
@@ -71,7 +71,7 @@ test("the filter menu searches its properties, archived included", async ({ page
await searchBox(menu).fill("lot");
// A substring match, not a fuzzy one: "Location" contains those letters in
// order but not together, and must not be offered here.
expect(await itemLabels(menu)).toEqual(["Lot Nr"]);
expect(await itemLabels(menu)).toEqual(["Lot Nr", "Show lot numbers"]);
// "Show Archived" is a filter like any other and answers to the same box.
await searchBox(menu).fill("arch");
@@ -101,3 +101,21 @@ test("the group menu is short enough to need no search box", async ({ page }) =>
await expect(menu.getByRole("button", { name: "None (flat)" })).toBeVisible();
await expect(searchBox(menu)).toHaveCount(0);
});
test("lot numbers toggle is searchable and updates the URL", async ({ page }) => {
await openApp(page);
let menu = await openToolbarMenu(page, "Filter");
await searchBox(menu).fill("show lot");
const toggle = menu.getByRole("menuitemcheckbox", { name: "Show lot numbers" });
await expect(toggle).toHaveAttribute("aria-checked", "false");
await toggle.click();
await expect(menu).toBeHidden();
await expect(page).toHaveURL(/lots=1/);
menu = await openToolbarMenu(page, "Filter");
await expect(menu.getByRole("menuitemcheckbox", { name: "Show lot numbers" })).toHaveAttribute("aria-checked", "true");
await searchBox(menu).fill("arch");
await expect(menu.getByRole("menuitemcheckbox", { name: "Show lot numbers" })).toHaveCount(0);
await searchBox(menu).fill("show lot");
await menu.getByRole("menuitemcheckbox", { name: "Show lot numbers" }).click();
await expect(page).not.toHaveURL(/lots=1/);
});
@@ -90,6 +90,7 @@ test.beforeAll(async ({ request }) => {
const PAGES = [
{ path: "/", name: "library" },
{ path: "/?lots=1", name: "library with lot numbers" },
{ path: "/dashboard", name: "dashboard" },
{ path: "/labels", name: "labels" },
{ path: "/settings", name: "settings" },