Most of the world writes 1,75 rather than 1.75, and `<input type="number">`
only accepts the separator the browser locale dictates: everything else it
reports as an empty value. A user typing a comma therefore watched a weight
or a diameter silently come out blank or, once the separator was dropped,
a hundred times too large. The same input type also accepts "e", "+" and "-"
anywhere in the text, since they are legal exponent syntax, and then reports
the whole value as empty too, so "1e" read back as nothing at all.
Numeric fields are now text inputs with `inputmode="decimal"`, guarded by a
shared `numericInput` action that filters at the keystroke: both separators
go in, letters cannot, and a pasted "12,5 g" is cleaned rather than refused.
Reading is centralised in `parseDecimal`, which takes either separator and
returns null instead of NaN for text that is not a number, so an unset field
stays distinguishable from a real 0. What the parent, and the API, receive is
always canonical.
The typed separator stays on screen: each field keeps a draft of the text and
gives it up only when the value moves somewhere that text no longer means, so
storing 1.5 does not rewrite a typed "1,5" between keystrokes. Arrow-key
stepping, which was native to the old input type, is now the component's own.
Covers the spool/filament/vendor inspectors, the add-spool modal (whose price
box was a bare text input accepting anything at all), extra fields of every
numeric type, the extra-field editor's order box and the low-stock threshold.
Folding the remembered grouping into parseLibraryState left the address
bar saying nothing while the list was grouped, and that gap broke the
group control: picking the default grouping serialises back to the very
same bare URL, and an unchanged URL means SvelteKit reuses its cached
load, so the choice never took effect. The load was also no longer a
pure function of the URL, which is what that per-URL cache assumes -
preloading the nav tab cached a redirect built from the old preference
and replayed it.
The page now navigates on arrival to the URL that spells the remembered
view out, replacing the history entry. The load goes back to reading the
URL alone, the view stays linkable, and every distinct view has a
distinct URL again. Only on arrival: a goto is a choice the user just
made and a popstate is Back, and overruling either would be wrong.
Group and sort live in the URL, so they survive a reload of a library
link but not a trip to Settings and back: every other route links to a
bare `/`, which handed back the shipped view and made a deliberately
chosen grouping something the user re-picked several times a day.
A URL that mentions neither grouping nor sorting now means "however I
had it last", restored from localStorage; one that mentions either still
wins outright, so a shared link shows its recipient what its sender saw.
Only the layout is remembered - filters, search and archived-visibility
hide spools, and one silently restored days later reads as missing data.
Fixes#1036
The list was a fixed 470px, which truncated longer filament names with no
way to see the rest. The divider between the list and the inspector is now
a draggable, focusable separator, and the width is remembered per browser.
Fixes#1034
Fixes#1019. A dashboard card grouped by a custom spool field counted more
spools than it listed: the group aggregate summed them, the card's own query
returned a subset.
Grouping and filtering disagreed about what "no value" means. group_by keys on
the decoded value, so a spool holding the JSON empty string a cleared text box
writes became its own group, separate from the null one; the client renders
both as "unassigned" and adds the counts. The card then fetches its spools with
`extra.<key>=`, whose empty branch matched only NULL and JSON null - never the
empty string. Built-in string columns have matched both NULL and '' since
forever (add_where_clause_str_opt), which is why grouping by location was fine.
- The empty extra-field filter matches a stored empty string too.
- Value-keyed group axes (extra fields, location, material) fold a blank into
NULL, so "unassigned" is one group rather than two the client can only draw
as one.
- Emptying a text extra field clears it instead of storing "", so no new spool
lands in the state above.
- The dashboard clears an extra field with a real JSON null. It sent the string
'null', which the API rejects as not-a-string, so dragging a spool onto the
unassigned card 400'd on every extra-field board.
The manufacturer was decided once, in the add-spool modal, and there was no
way back: a filament created under the wrong brand — or under none — stayed
there, and since the library groups by manufacturer that mis-files every
spool of it. The companion to the spool's change-filament (#1010), one level
up the chain.
Adds a "Change" action to the MANUFACTURER section of the filament
inspector, opening a dialog with the three destinations the API allows: one
of your manufacturers, a new one named on the spot (as the add-spool form
already allows), or none at all. There is no SpoolmanDB side to this one —
the external catalog carries filaments and materials, not manufacturers — so
the picker fetches the vendor list whole and filters it in the browser.
The dialog spells out the one consequence that isn't obvious. A filament
with no empty spool weight of its own takes its manufacturer's, and that is
the figure `PUT /spool/{id}/measure` subtracts from a reading off the scale,
so the note says what it becomes — or that it is about to have none. A
filament that has its own keeps it and gets no note: unlike the spool's full
weight, that field is editable right there in the panel.
Clearing the manufacturer travels as an explicit `vendor_id: null`, which is
what the API reads as "no manufacturer"; dropping the key instead would have
made that choice a silent no-op.
A collapsed "×N unused" row speaks for its whole pile with one filament's
name, swatch, weight and location, all read off the first spool. That only
holds within a filament group. Under Manufacturer (or Material/Location) a
group spans many filaments, so the whole unused pile was filed under
whichever filament happened to sort first: five different SUNLU woods
listed as "Wood Cherry ×5 unused" (#1012).
Bucket the pile by filament before collapsing it, keeping the backend's
order via first appearance. Buckets of one render inline as before.
Fixes#1012