fix(review): thickness band derived from thickness_in (#88); MultiSelect shows selected names + commits on toggle settle (#90)
#88 — thickness band is now display-only, derived deterministically from thickness_in against the synced Woo thickness terms (TV-9 / QUALITY-95). Replaces the <select> with a read-only div. New pure function deriveThicknessTermId + 6 unit tests. Docs (UAT-PRACTICE, contracts/UX) updated to reflect the band is not an owner control. #90 — MultiSelect closed state now shows the selected option names (truncated to 2 + +N) instead of just a count. onCommit prop wired so multi-select changes persist + re-review immediately (250ms debounce, flush on Done, cleanup on destroy). Single-select binds use string vars (speciesSel/edgeSel/gradeSel) to fix the number/string mismatch with <option value={id}>; a tick()-deferred syncSelects() keeps the DOM option in sync after programmatic writes (AI prefill, reload hydrate) that Svelte's own select_option would otherwise reset.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Deterministic thickness-band derivation (TV-9 / QUALITY-95, METADATA-15/25).
|
||||
*
|
||||
* The Woo "Thickness" attribute is a set of named bands (e.g. "2-4 in"). The
|
||||
* owner enters a numeric thickness in 1/8-inch steps; the band is derived by
|
||||
* rounding that thickness UP into the band whose numeric upper bound first
|
||||
* encloses it. The Review UI never exposes a band picker — the band always
|
||||
* follows from thickness_in (issue #88).
|
||||
*
|
||||
* Pure function so it is unit-testable without a browser/backend.
|
||||
*/
|
||||
|
||||
export interface ThicknessBand {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Parse the leading numeric bounds ("2-4 in" → {min:2,max:4}). Returns null if the name
|
||||
* carries no usable numeric upper bound (defensive — a band we can't place is skipped). */
|
||||
function parseBounds(name: string): { min: number; max: number } | null {
|
||||
// Match the first two numbers in the name, in order (handles "2-4 in", "1/8", "12in+").
|
||||
const nums = name.match(/\d+(?:[./]\d+)?/g);
|
||||
if (!nums) return null;
|
||||
const asNum = (s: string) => Number(s);
|
||||
const min = asNum(nums[0]);
|
||||
const max = nums.length >= 2 ? asNum(nums[1]) : min;
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
|
||||
if (max < min) return null; // malformed "4-2"; not a usable band
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round `thicknessIn` (inches, 1/8" steps) up into the synced Woo thickness band.
|
||||
* Returns the term id, or '' when no band encloses the value (no thickness known,
|
||||
* empty synced set, or a value above every band — all leave the field empty so the
|
||||
* owner's upstream fix, not a wrong band, resolves it).
|
||||
*/
|
||||
export function deriveThicknessTermId(
|
||||
thicknessIn: number | null | undefined,
|
||||
bands: ThicknessBand[],
|
||||
): number | '' {
|
||||
if (typeof thicknessIn !== 'number' || !Number.isFinite(thicknessIn) || thicknessIn <= 0) {
|
||||
return '';
|
||||
}
|
||||
// Sort bands by upper bound so "first enclosing max" is well-defined regardless of
|
||||
// the order Woo returned them in.
|
||||
const ranked = bands
|
||||
.map((b) => ({ b, bounds: parseBounds(b.name) }))
|
||||
.filter((x): x is { b: ThicknessBand; bounds: { min: number; max: number } } => x.bounds !== null)
|
||||
.sort((a, z) => a.bounds.max - z.bounds.max);
|
||||
for (const { b, bounds } of ranked) {
|
||||
// A value exactly on the band's max belongs to that band; above it, fall through.
|
||||
if (thicknessIn <= bounds.max) {
|
||||
return b.id;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
Reference in New Issue
Block a user