feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green

Clean re-implementation of the full product (not a port of the old M3 tree):
- P0 foundation: 13-table schema + per-call sqlite factory (thread-safe),
  FastAPI app (health, JSON logging + secret redaction, CORS, startup guard).
- P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure,
  png crop/inventory/overlay, EXIF decodeOrientedBitmap.
- P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop
  to batch) + photos-ready lineup.
- P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing,
  inference Call1/Call2, review, publish, settings, slabs, photos) + real FE
  review/publish/settings screens consuming the API (no hardcoded Woo ids).
- P4 gate exit: check_requirements + check_coverage both exit 0
  (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green.

Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
2026-09-25 06:57:29 -07:00
parent 4ca2ef57a7
commit 49c22c9385
106 changed files with 5502 additions and 8863 deletions
+387
View File
@@ -0,0 +1,387 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { apiFetch, ApiError } from '../lib/api';
import type { SlabDraft, Taxonomy, TaxonomyCategory, TaxonomyTerm } from '../lib/types';
import { deriveThicknessBandId } from './thicknessBand';
import { keptInOrder, missingMinimums, moveItem, roleLabel, resolvePhotoUrl, type LineupItem, type PhotoRole } from './lineup';
import { nextPrimary, selectedSummary } from './woodPrimary';
import { buildShortDescription, buildTitle } from './copy';
export let slab: SlabDraft;
export let taxonomy: Taxonomy | null = null;
// Children of a synced Woo grouper, resolved by slug with a name fallback
// (QUALITY-105 / METADATA-5 — never hardcoded ids).
function leafChildren(categories: TaxonomyCategory[], slug: string, name: string): TaxonomyCategory[] {
if (!categories.length) return [];
const grouper =
categories.find((c) => (c.slug || '').toLowerCase() === slug) ??
categories.find((c) => (c.name || '').trim().toLowerCase() === name);
if (!grouper) return [];
return categories
.filter((c) => c.parent_id === grouper.id && c.id !== grouper.id)
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
const dispatch = createEventDispatcher<{
ready: Partial<SlabDraft>;
back: void;
taxonomy: Taxonomy;
saved: SlabDraft;
}>();
// ── Taxonomy lookups (from the synced /api/v1 response — never hardcoded) ──
$: categories = taxonomy?.categories ?? [];
$: speciesLeaves = leafChildren(categories, 'species', 'Species');
$: woodCatLeaves = leafChildren(categories, 'wcat', 'Wood category');
$: attributeByName = (name: string) =>
(taxonomy?.attributes ?? []).find((a) => a.name.toLowerCase() === name || a.slug.toLowerCase() === name);
$: edgeTerms = attributeByName('Edge Type')?.terms ?? [];
$: figureTerms = attributeByName('Figure')?.terms ?? [];
$: gradeTerms = attributeByName('Grade')?.terms ?? [];
$: thicknessTerms = attributeByName('Thickness')?.terms ?? [];
$: moistureTerms = attributeByName('Moisture')?.terms ?? [];
$: figTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('fig-'));
$: featTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('feat-'));
$: speciesName = (id: number | null) => categories.find((c) => c.id === id)?.name ?? '';
$: figureName = figureTerms.find((t) => t.id === (slab.figureTermIds?.[0] ?? null))?.name ?? null;
// ── Draft fields ──
let sku = slab.sku || '';
let speciesId: number | '' = slab.speciesId ?? '';
let woodCatIds: number[] = slab.woodCategoryIds.length ? [...slab.woodCategoryIds] : [];
let primaryCatId: number | '' = slab.woodCategoryIds[0] ?? '';
let edgeId: number | '' = slab.edgeTypeTermId ?? '';
let figIds: number[] = slab.figureTermIds.length ? [...slab.figureTermIds] : [];
let gradeId: number | '' = slab.gradeTermId ?? '';
let moistId: number | '' = slab.moistureTermId ?? '';
let figTagIds: number[] = slab.figTagIds ?? [];
let featTagIds: number[] = slab.featTagIds ?? [];
let price = slab.price || 0;
let priceSource: 'recommendation' | 'override' = slab.priceSource;
let title = slab.title || '';
let shortDesc = slab.shortDescription || '';
let description = slab.description || '';
// Derived thickness band (METADATA-15 / QUALITY-95): no picker, follows thickness_in.
$: bandId = deriveThicknessBandId(slab.thicknessIn, thicknessTerms);
$: bandName = thicknessTerms.find((t) => t.id === bandId)?.name ?? '';
$: edgeName = edgeTerms.find((t) => t.id === edgeId)?.name ?? null;
$: primaryLeaf = categories.find((c) => c.id === primaryCatId);
function regenerateCopy() {
if (!primaryLeaf || !speciesName(speciesId as number)) return;
title = buildTitle(
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
figureName,
speciesName(speciesId as number),
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
);
shortDesc = buildShortDescription(
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
figureName,
speciesName(speciesId as number),
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
);
}
// ── Price: bdft × species rate (REVIEW-25); owner override sticks ──
$: priceRec = priceSuggestion();
function priceSuggestion(): number | null {
const rule = (slab as SlabDraft & { speciesRate?: number | null }).speciesRate;
if (!slab.bdft || rule == null) return null;
return Math.round(slab.bdft * rule * 100) / 100;
}
function useRecommendedPrice() {
if (priceRec == null) return;
price = priceRec;
priceSource = 'recommendation';
}
function onPriceInput() {
price = Math.round(price * 100) / 100;
priceSource = 'override';
}
// ── Photo strip (REVIEW-5): kept order, leftmost = primary (PUBLISH-45) ──
$: lineup = slab.photos.map((photo, index): LineupItem => ({
id: photo.id,
role: photo.id === slab.calibrationPhotoId ? 'calibration' : photo.processedBlob ? 'overlay' : 'batch',
isCalibration: photo.id === slab.calibrationPhotoId,
kept: index < slab.photos.length,
}));
$: lineupMissing = missingMinimums(lineup);
$: primaryPhoto = keptInOrder(lineup)[0];
function toggleKeep(id: string) {
const next = lineup.map((it) => (it.id === id ? { ...it, kept: !it.kept } : it));
resequence(next);
}
function movePhoto(from: number, to: number) {
resequence(moveItem(lineup, from, to));
}
function resequence(items: LineupItem[]) {
const kept = keptInOrder(items);
const update: Partial<SlabDraft> = {
photos: kept.map((it, i) => {
const photo = slab.photos.find((p) => p.id === it.id);
return photo ? { ...photo, seq: i + 1 } : photo;
}),
};
dispatch('saved', { ...slab, ...update, clientRev: slab.clientRev + 1 });
}
function photoSrc(photoId: string, role: PhotoRole): string {
const photo = slab.photos.find((p) => p.id === photoId);
if (!photo) return '';
if (role === 'batch') return photo.processedBlob ? resolvePhotoUrl(photo.processedBlob, photo.url) : photo.url;
if (role === 'overlay') return photo.processedBlob ? resolvePhotoUrl(photo.processedBlob, photo.url) : photo.url;
return photo.url;
}
// ── Multi-select helpers ──
$: woodCatNames = new Map(woodCatLeaves.map((c) => [c.id, c.name]));
$: figNames = new Map(figureTerms.map((t) => [t.id, t.name]));
function toggleWoodCat(id: number) {
const selected = woodCatIds.includes(id) ? woodCatIds.filter((x) => x !== id) : [...woodCatIds, id];
woodCatIds = selected;
primaryCatId = nextPrimary(selected, primaryCatId);
regenerateCopy();
}
function toggleFig(id: number) {
figIds = figIds.includes(id) ? figIds.filter((x) => x !== id) : [...figIds, id];
regenerateCopy();
}
function toggleTag(list: number[], id: number): number[] {
return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
// ── Call 2: long description (METADATA-115) with template fallback ──
let genState: 'idle' | 'loading' | 'ok' | 'failed' = 'idle';
let genMessage = '';
function templateDescription(): string {
return (
`A ${title || 'slab'} from the mill, measured at ${slab.lengthIn}in long, ` +
`${slab.widthAvgIn}in average width, and ${slab.thicknessIn}in thick (${slab.bdft} bdft). ` +
'Ready to ship.'
);
}
function useTemplateDescription() {
description = templateDescription();
genState = 'idle';
genMessage = 'Template description applied.';
}
async function generateDescription() {
genState = 'loading';
genMessage = '';
try {
const res = await apiFetch<{ ok: boolean; description: string }>(`/slabs/${slab.id}/inference/call2`, { method: 'POST' });
description = res.description;
genState = 'ok';
genMessage = 'Long description generated (Call 2).';
} catch (err) {
genState = 'failed';
genMessage = err instanceof ApiError ? `${err.message} — use the template fallback.` : 'Generation failed — use the template fallback.';
}
}
// ── Ready gate (REVIEW-5) ──
$: skuOk = /^[A-Za-z0-9-]{3,24}$/.test(sku.trim());
$: ready =
skuOk && speciesId !== '' && woodCatIds.length > 0 && edgeId !== '' && figIds.length > 0 &&
gradeId !== '' && bandId !== null && moistId !== '' && price > 0 && title.trim().length > 0 &&
shortDesc.trim().length > 0 && description.trim().length > 0;
$: missing = [];
$: if (!skuOk) missing.push('SKU (3–24 alnum/-)');
$: if (speciesId === '') missing.push('Species');
$: if (!woodCatIds.length) missing.push('Wood category');
$: if (edgeId === '') missing.push('Edge type');
$: if (!figIds.length) missing.push('Figure');
$: if (gradeId === '') missing.push('Grade');
$: if (bandId === null) missing.push('Thickness band (set thickness)');
$: if (moistId === '') missing.push('Moisture');
$: if (!(price > 0)) missing.push('Price');
$: if (!description.trim()) missing.push('Description');
async function submitReady() {
if (!ready) return;
const update: Partial<SlabDraft> = {
sku: sku.trim(),
speciesId: speciesId as number,
woodCategoryIds: woodCatIds,
edgeTypeTermId: edgeId as number,
figureTermIds: figIds,
gradeTermId: gradeId as number,
thicknessTermId: bandId,
moistureTermId: moistId as number,
figTagIds,
featTagIds,
price,
priceSource,
title,
shortDescription: shortDesc,
description,
status: 'ready',
};
dispatch('ready', update);
}
</script>
<section>
<h2>3. Review (METADATA/REVIEW)</h2>
{#if !taxonomy}
<p>Loading synced taxonomy…</p>
{:else if !categories.length}
<p class="nudge">Taxonomy is empty — run a Woo sync in Settings first.</p>
{:else}
<div class="form">
<label>SKU <input bind:value={sku} maxlength="24" placeholder="SLAB-UAT-001" /></label>
<label>Species
<select bind:value={speciesId} on:change={regenerateCopy}>
<option value="" disabled>Select…</option>
{#each speciesLeaves as s (s.id)}<option value={s.id}>{s.name}</option>{/each}
</select>
</label>
<div class="chip-group">
<span>Wood categories <small>— primary drives the title</small></span>
{#each woodCatLeaves as c (c.id)}
<button class="chip" class:selected={woodCatIds.includes(c.id)} on:click={() => toggleWoodCat(c.id)}>
{#if primaryCatId === c.id}<strong>★</strong>{/if}{c.name}
</button>
{/each}
<small class="chip-sum">{selectedSummary(woodCatIds, woodCatNames, 'None selected')}</small>
</div>
<label>Edge type
<select bind:value={edgeId} on:change={regenerateCopy}>
<option value="" disabled>Select…</option>
{#each edgeTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
</select>
</label>
<div class="chip-group">
<span>Figure (multi)</span>
{#each figureTerms as t (t.id)}
<button class="chip" class:selected={figIds.includes(t.id)} on:click={() => toggleFig(t.id)}>{t.name}</button>
{/each}
</div>
<label>Grade
<select bind:value={gradeId}>
<option value="" disabled>Select…</option>
{#each gradeTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
</select>
</label>
<div class="band">
<span>Thickness band (derived from {slab.thicknessIn}″)</span>
<strong>{bandName || '— set thickness in Measure —'}</strong>
</div>
<label>Moisture
<select bind:value={moistId}>
<option value="" disabled>Select…</option>
{#each moistureTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
</select>
</label>
{#if figTags.length}
<div class="chip-group">
<span>Figure tags</span>
{#each figTags as t (t.id)}
<button class="chip" class:selected={figTagIds.includes(t.id)} on:click={() => (figTagIds = toggleTag(figTagIds, t.id))}>{t.name}</button>
{/each}
</div>
{/if}
{#if featTags.length}
<div class="chip-group">
<span>Feature tags</span>
{#each featTags as t (t.id)}
<button class="chip" class:selected={featTagIds.includes(t.id)} on:click={() => (featTagIds = toggleTag(featTagIds, t.id))}>{t.name}</button>
{/each}
</div>
{/if}
<div class="price">
<label>Price ($) <input type="number" step="0.01" bind:value={price} on:input={onPriceInput} /></label>
{#if priceRec != null}
<button on:click={useRecommendedPrice}>Use recommendation ${priceRec} (bdft × species rate)</button>
{:else}
<small class="nudge">No species rate configured — set it in Settings.</small>
{/if}
<small>Source: {priceSource}</small>
</div>
<div class="prose">
<label>Title (deterministic, editable) <input bind:value={title} /></label>
<label>Short description (deterministic, editable) <input bind:value={shortDesc} /></label>
<label>Long description
<textarea bind:value={description} rows="5"></textarea>
</label>
<div class="gen">
<button on:click={generateDescription} disabled={genState === 'loading'}>
{genState === 'loading' ? 'Generating…' : 'Generate (Call 2)'}
</button>
<button on:click={useTemplateDescription}>Use template</button>
{#if genMessage}<p class:ok={genState === 'ok'} class:err={genState === 'failed'}>{genMessage}</p>{/if}
</div>
</div>
<div class="strip">
<span>Photo lineup — leftmost kept = primary (cover). Tap to keep/remove; use ‹ › to reorder.</span>
{#each lineup as item, i (item.id)}
{#if item.kept}
<div class="strip-item" class:primary={item.id === primaryPhoto?.id}>
<button class="keep" aria-label="keep or remove" on:click={() => toggleKeep(item.id)}>{item.id === primaryPhoto?.id ? '★' : '✓'}</button>
<img src={photoSrc(item.id, item.role)} alt={roleLabel(item.role)} />
<div class="strip-actions">
<button disabled={i === 0} on:click={() => movePhoto(i, i - 1)}>‹</button>
<button disabled={i === lineup.length - 1} on:click={() => movePhoto(i, i + 1)}>›</button>
</div>
<small>{roleLabel(item.role)}</small>
</div>
{/if}
{/each}
{#if lineupMissing.length}
<p class="nudge">Minimum not met — missing {lineupMissing.map(roleLabel).join(' and ')}.</p>
{/if}
</div>
</div>
{#if !ready}
<p class="nudge">Still needed: {missing.join(', ')}</p>
{/if}
<div class="actions">
<button on:click={() => dispatch('back')}>← Back to measure</button>
<button on:click={submitReady} disabled={!ready} class="primary">Mark Ready & Publish →</button>
</div>
{/if}
</section>
<style>
.form { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:0.9rem; }
label { display:block; font-size:0.9rem; }
input,select,textarea { width:100%; padding:0.4rem; background:#222; color:#eee; border:1px solid #555; margin-top:0.2rem; }
.chip-group { display:flex; flex-wrap:wrap; gap:0.35rem; align-items:center; }
.chip { background:#222; color:#eee; border:1px solid #555; padding:0.35rem 0.7rem; border-radius:999px; cursor:pointer; }
.chip.selected { background:#0a7; color:#fff; border-color:#0a7; }
.chip-sum { width:100%; color:#888; }
.band { background:#1a1a1a; padding:0.6rem 0.8rem; border-radius:4px; }
.band strong { display:block; color:#0af; }
.price button { margin-top:0.3rem; font-size:0.8rem; }
.gen { display:flex; gap:0.5rem; align-items:center; margin-top:0.3rem; }
.gen .ok { color:#0a7; margin:0; }
.gen .err { color:#f88; margin:0; }
.strip { grid-column:1/-1; }
.strip-item { position:relative; display:inline-block; margin:0.3rem; }
.strip-item img { width:140px; height:140px; object-fit:cover; background:#111; border:2px solid #444; }
.strip-item.primary img { border-color:#0a7; }
.keep { position:absolute; top:4px; left:4px; z-index:1; background:#111; color:#0a7; border:1px solid #0a7; border-radius:4px; }
.strip-actions { display:flex; gap:0.25rem; }
.nudge { color:#fa0; }
.primary { background:#0a7; color:white; border:none; }
.actions { display:flex; gap:0.75rem; margin-top:1rem; }
</style>
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildShortDescription, buildTitle, type CopyFacts } from './copy';
const FACTS: CopyFacts = { lengthIn: 48, widthAvgIn: 18, thicknessIn: 2, bdft: 12 };
test('copy: live-edge leaf title, no dims, figure drops when absent', () => {
assert.equal(
buildTitle(FACTS, 'Cathedral', 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
'Cathedral Black Walnut Live Edge Slab',
);
assert.equal(
buildTitle(FACTS, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
'Black Walnut Live Edge Slab',
);
});
test('copy: dimensional leaf gains "Live Edge" only from the synced edge term (METADATA-5)', () => {
assert.equal(
buildTitle({ ...FACTS, edgeName: 'Live' }, null, 'Red Oak', { slug: 'dimensional-wood', name: 'Dimensional wood' }),
'Red Oak Live Edge Dimensional',
);
assert.equal(
buildTitle({ ...FACTS, edgeName: 'Straight' }, null, 'Red Oak', { slug: 'dimensional-wood', name: 'Dimensional wood' }),
'Red Oak Dimensional',
);
});
test('copy: knife-blank leaf carries dims', () => {
assert.equal(
buildTitle(FACTS, null, 'Cherry', { slug: 'knife-blanks', name: 'Knife blanks' }),
'Cherry Knife Blank (48 x 18 x 2 in)',
);
});
test('copy: unknown leaf slug falls back to "Slab"', () => {
assert.equal(
buildTitle(FACTS, 'Spalted', 'Olive', { slug: 'mystery-leaf', name: 'Mystery' }),
'Spalted Olive Slab',
);
});
test('copy: title collapses double spaces', () => {
assert.equal(
buildTitle(FACTS, ' ', 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
'Black Walnut Live Edge Slab',
);
});
test('copy: short description = title + ft/in split + dims + rounded bdft', () => {
assert.equal(
buildShortDescription(FACTS, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
'Black Walnut Live Edge Slab, 4ft 0in x 18in x 2in, 12 bdft',
);
});
test('copy: short description keeps fractional bdft to 1 dp', () => {
assert.equal(
buildShortDescription({ ...FACTS, bdft: 11.46 }, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
'Black Walnut Live Edge Slab, 4ft 0in x 18in x 2in, 11.5 bdft',
);
});
test('copy: non-whole feet split into ft + rem inches', () => {
assert.equal(
buildShortDescription({ ...FACTS, lengthIn: 60 }, null, 'Cherry', { slug: 'live-edge-wood', name: 'Live edge wood' }),
'Cherry Live Edge Slab, 5ft 0in x 18in x 2in, 12 bdft',
);
assert.equal(
buildShortDescription({ ...FACTS, lengthIn: 41.5 }, null, 'Cherry', { slug: 'live-edge-wood', name: 'Live edge wood' }),
'Cherry Live Edge Slab, 3ft 5.5in x 18in x 2in, 12 bdft',
);
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Deterministic title / short description (METADATA-80/85).
*
* Mirrors the backend's `build_title` / `build_short_description` so the Review
* screen previews exactly what the server will assemble. Patterns interpolate
* {figure}, {species}, {length}, {width}, {thickness}, {bdft}; figure is a
* Title-Case adjective and an absent figure drops the slot. Leaf slugs are the
* synced Woo slugs — ids are never hardcoded (QUALITY-105 / METADATA-5).
*/
export interface CopyFacts {
lengthIn: number;
widthAvgIn: number;
thicknessIn: number;
bdft: number;
/** Name of the selected edge term; a name containing "live" flags live edge (METADATA-5). */
edgeName?: string | null;
}
export interface CopyLeaf {
slug: string;
name: string;
}
/** (pattern, noun, carriesDims) per primary wood-category leaf slug. */
const LEAF_PATTERNS: Record<string, { pattern: string; noun: string; carriesDims: boolean }> = {
'live-edge-wood': { pattern: '{figure}{species} Live Edge Slab', noun: 'Live Edge Slab', carriesDims: false },
'dimensional-wood': { pattern: '{figure}{species} {live_edge}Dimensional', noun: 'Dimensional', carriesDims: false },
'knife-blanks': { pattern: '{figure}{species} Knife Blank ({dims})', noun: 'Knife Blank', carriesDims: true },
'bowl-blanks': { pattern: '{figure}{species} {live_edge}Bowl Blank ({dims})', noun: 'Bowl Blank', carriesDims: true },
'logs-and-caps': { pattern: '{figure}{species} Log Cap', noun: 'Log Cap', carriesDims: false },
'pen-blanks': { pattern: '{figure}{species} Pen Blank ({dims})', noun: 'Pen Blank', carriesDims: true },
'pepper-mill-blanks': { pattern: '{figure}{species} Pepper Mill Blank ({dims})', noun: 'Pepper Mill Blank', carriesDims: true },
};
const FALLBACK_PATTERN = { pattern: '{figure}{species} Slab', noun: 'Slab', carriesDims: false };
function fmt1(v: number): string {
const s = v.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
}
function fmt2(v: number): string {
return v.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
function dims(facts: CopyFacts): string {
return `${fmt1(facts.lengthIn)} x ${fmt1(facts.widthAvgIn)} x ${fmt2(facts.thicknessIn)} in`;
}
function titleCase(s: string): string {
return s.replace(/\b\w/g, (c) => c.toUpperCase());
}
function collapse(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
function splitLength(lengthIn: number): [number, number] {
const ft = Math.floor(lengthIn / 12);
const rem = Math.round((lengthIn - ft * 12) * 10) / 10;
return [ft, rem];
}
function edgeIsLive(edgeName: string | null | undefined): boolean {
return (edgeName ?? '').toLowerCase().includes('live');
}
export function buildTitle(facts: CopyFacts, figure: string | null, speciesName: string, primaryLeaf: CopyLeaf): string {
const { pattern, carriesDims } = LEAF_PATTERNS[primaryLeaf.slug] ?? FALLBACK_PATTERN;
const fig = (figure ?? '').trim();
// The live-edge flag is driven by the *synced edge term* for dimensional/bowl
// forms — never by any non-null edge id (METADATA-5 / #98).
const liveEdge =
(primaryLeaf.slug === 'dimensional-wood' || primaryLeaf.slug === 'bowl-blanks') && edgeIsLive(facts.edgeName)
? 'Live Edge '
: '';
const title = pattern
.replace('{figure}', fig ? `${titleCase(fig)} ` : '')
.replace('{species}', speciesName)
.replace('{live_edge}', liveEdge)
.replace('{dims}', carriesDims ? dims(facts) : '');
return collapse(title);
}
export function buildShortDescription(facts: CopyFacts, figure: string | null, speciesName: string, primaryLeaf: CopyLeaf): string {
const title = buildTitle(facts, figure, speciesName, primaryLeaf);
const [ft, rem] = splitLength(facts.lengthIn);
const line = `${title}, ${ft}ft ${fmt1(rem)}in x ${fmt1(facts.widthAvgIn)}in x ${fmt2(facts.thicknessIn)}in, ${fmt1(Math.round(facts.bdft * 10) / 10)} bdft`;
return collapse(line);
}
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
keptInOrder,
moveItem,
missingMinimums,
lineupValid,
roleLabel,
resolvePhotoUrl,
type LineupItem,
} from './lineup';
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
id,
role,
isCalibration: role === 'calibration',
kept,
});
test('lineup: keptInOrder returns kept photos in their current order', () => {
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
});
test('lineup: leftmost kept is the primary', () => {
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
// After a drag the owner re-ordered; primary follows position, not role.
assert.equal(keptInOrder(items)[0].id, 'b1');
});
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
assert.equal(keptInOrder(items)[0].id, 'ov');
});
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
const a = ['x', 'y', 'z'];
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
// Out-of-range is a no-op, never throws.
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
// Returns a new array, does not mutate the input.
const before = a.slice();
moveItem(a, 0, 2);
assert.deepEqual(a, before);
});
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
});
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
assert.equal(lineupValid([p('b1', 'batch')]), false);
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
});
test('lineup: missingMinimums only counts KEPT photos', () => {
// Both roles present in the array, but the cal is not kept.
assert.deepEqual(missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]), ['calibration']);
});
test('lineup: roleLabel is owner-facing', () => {
assert.match(roleLabel('calibration'), /clean/);
assert.match(roleLabel('overlay'), /overlay/);
});
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL', () => {
const blob = new Blob(['png-bytes'], { type: 'image/png' });
const fake = (b: Blob) => `blob:fake-${b.size}`;
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
// Empty/absent blob falls back to the stored endpoint.
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Review photo-strip lineup (REVIEW-5 / PUBLISH-45). Pure logic so keep/remove,
* reorder, and the submission minimum are unit-testable without a browser.
*
* The owner taps photos to keep/remove and drags to reorder; the LEFTMOST kept
* photo is the primary (Woo cover). Submission minimum: at least one clean
* calibration photo AND its dimension-overlay copy (REVIEW-5). Batch photos
* are optional.
*/
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
export interface LineupItem {
id: string;
role: PhotoRole;
isCalibration: boolean;
/** True = the owner keeps this photo in the submission. */
kept: boolean;
}
/** The kept photos in their current (left→right) order; the first is the primary. */
export function keptInOrder(items: LineupItem[]): LineupItem[] {
return items.filter((it) => it.kept);
}
/** Move an item from one index to another (drag-reorder). Returns a new array. */
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
return arr;
}
const next = arr.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/**
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
* clean calibration photo and one dimension-overlay copy. Returns the missing
* role(s) so the UI can name them in a nudge.
*/
export function missingMinimums(items: LineupItem[]): PhotoRole[] {
const kept = items.filter((it) => it.kept);
const missing: PhotoRole[] = [];
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
return missing;
}
/** True when the kept lineup is publishable from a photo standpoint. */
export function lineupValid(kept: LineupItem[]): boolean {
return missingMinimums(kept).length === 0;
}
/** A short owner-facing label for a role (used in the minimum nudge). */
export function roleLabel(role: PhotoRole): string {
if (role === 'calibration') return 'a clean photo';
if (role === 'overlay') return 'its dimension-overlay copy';
return 'a batch photo';
}
/**
* Resolve the <img> src for a strip photo. A fresh in-memory blob must become
* a `blob:` object URL — assigning a raw Blob renders a broken image. With no
* usable blob, fall back to the stored endpoint.
*
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
* is unit-testable without a real DOM/Blob.
*/
export function resolvePhotoUrl(
blob: Blob | null,
endpoint: string,
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
): string {
if (blob && blob.size > 0) return createObjectUrl(blob);
return endpoint;
}
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { deriveThicknessBandId, parseInchToken, termBounds, type ThicknessTerm } from './thicknessBand';
test('parseInchToken: whole, decimal, fraction, mixed number, quoted', () => {
assert.equal(parseInchToken('2'), 2);
assert.equal(parseInchToken('1.5'), 1.5);
assert.equal(parseInchToken('3/4'), 0.75);
assert.equal(parseInchToken('1 1/2'), 1.5);
assert.equal(parseInchToken('2"'), 2);
assert.equal(parseInchToken(' 1 1/2 '), 1.5);
assert.equal(parseInchToken(''), null);
assert.equal(parseInchToken('N/A'), null);
assert.equal(parseInchToken(null), null);
});
test('termBounds: name first, slug fallback, malformed → null', () => {
assert.deepEqual(termBounds({ id: 1, name: '1"–1 1/2"' }), [1, 1.5]);
assert.deepEqual(termBounds({ id: 2, name: '1 1/2"–3"', slug: 'x' }), [1.5, 3]);
assert.deepEqual(termBounds({ id: 3, name: '3"+' }), [3, Infinity]);
assert.deepEqual(termBounds({ id: 4, name: 'under 1"', slug: 'x' }), [0, 1]);
assert.deepEqual(termBounds({ id: 5, name: 'over 6"', slug: 'x' }), [6, Infinity]);
assert.deepEqual(termBounds({ id: 6, name: 'x', slug: '4-6qtr' }), [1, 1.5]);
assert.deepEqual(termBounds({ id: 7, name: 'x', slug: '12qtr' }), [3, Infinity]);
assert.deepEqual(termBounds({ id: 8, name: 'x', slug: 'xthin' }), [0, 0.5]);
assert.equal(termBounds({ id: 9, name: 'N/A', slug: 'zzz' }), null);
});
test('deriveThicknessBandId: half-open [low, high) — boundary belongs to the starting band', () => {
const terms: ThicknessTerm[] = [
{ id: 10, name: '3/4"–1"' },
{ id: 11, name: '1"–1 1/2"' },
{ id: 12, name: '1 1/2"–3"' },
];
assert.equal(deriveThicknessBandId(0.75, terms), 10);
assert.equal(deriveThicknessBandId(0.9, terms), 10);
// Exactly on the 1" shared boundary → the band that starts at 1".
assert.equal(deriveThicknessBandId(1, terms), 11);
assert.equal(deriveThicknessBandId(1.5, terms), 12);
assert.equal(deriveThicknessBandId(2.5, terms), 12);
});
test('deriveThicknessBandId: 1/8" steps land in the enclosing band', () => {
const terms: ThicknessTerm[] = [
{ id: 39, name: '2-4 in' },
{ id: 10, name: '1-2 in' },
];
assert.equal(deriveThicknessBandId(1, terms), 10);
assert.equal(deriveThicknessBandId(1.5, terms), 10);
assert.equal(deriveThicknessBandId(2, terms), 39); // 2 is the upper bound of 1-2 → excluded, falls into 2-4
assert.equal(deriveThicknessBandId(3.875, terms), 39);
});
test('deriveThicknessBandId: order-independent, gap fallback by nearest lower edge', () => {
const terms: ThicknessTerm[] = [
{ id: 12, name: '1 1/2"–3"' },
{ id: 11, name: '1"–1 1/2"' },
];
assert.equal(deriveThicknessBandId(1.25, terms), 11);
// Gap below 1": nearest band by lower edge.
assert.equal(deriveThicknessBandId(0.5, terms), 11);
});
test('deriveThicknessBandId: unbounded "over" band catches large values', () => {
const terms: ThicknessTerm[] = [
{ id: 1, name: '3/4"–1"' },
{ id: 2, name: '1"+' },
];
assert.equal(deriveThicknessBandId(12, terms), 2);
});
test('deriveThicknessBandId: null / invalid input → null (no invented band)', () => {
const terms: ThicknessTerm[] = [{ id: 1, name: '1-2 in' }];
assert.equal(deriveThicknessBandId(null, terms), null);
assert.equal(deriveThicknessBandId(undefined, terms), null);
assert.equal(deriveThicknessBandId(-1, terms), null);
assert.equal(deriveThicknessBandId(Number.NaN, terms), null);
assert.equal(deriveThicknessBandId(1, []), null);
assert.equal(deriveThicknessBandId(1, [{ id: 1, name: 'N/A' }]), null);
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Deterministic thickness-band derivation (METADATA-15 / QUALITY-95).
*
* Mirrors the backend's `resolve_thickness_band_id` so the Review screen can
* preview the band the server will store: the owner enters thickness in 1/8"
* steps and the band follows — the UI never exposes a band picker. Bands are
* parsed from the live synced term names/slugs, never hardcoded ids.
*
* Round-up semantics: bands are half-open on the upper bound, so a value on a
* shared boundary belongs to the band that *starts* there ("1"–1 1/2" wins over
* "3/4"–1").
*/
export interface ThicknessTerm {
id: number;
name: string;
slug?: string;
}
/** Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → inches. */
export function parseInchToken(text: string | undefined | null): number | null {
const t = (text ?? '').trim().replace(/["']\s*$/, '').trim();
if (!t) return null;
const m =
t.match(/^(?:(\d+)\s+(\d+)\s*\/\s*(\d+)|(\d+)\s*\/\s*(\d+)|(\d+(?:\.\d+)?))$/i) ??
t.match(/(?:(\d+)\s+(\d+)\s*\/\s*(\d+)|(\d+)\s*\/\s*(\d+)|(\d+(?:\.\d+)?))/i);
if (!m) return null;
if (m[1] != null) return Number(m[1]) + Number(m[2]) / Number(m[3]);
if (m[4] != null) return Number(m[4]) / Number(m[5]);
if (m[6] != null) return Number(m[6]);
return null;
}
/** Half-open [low, high) inches from one synced term; name first, slug fallback. */
export function termBounds(term: ThicknessTerm): [number, number] | null {
return boundsFromName(String(term.name ?? '')) ?? boundsFromSlug(String(term.slug ?? ''));
}
function boundsFromName(raw: string): [number, number] | null {
const text = raw.trim();
if (!text) return null;
const low = text.toLowerCase();
// Under / below / less-than / < X
let m = low.match(/^(?:under|below|less than|<)\s*(.+)$/);
if (m) {
const hi = parseInchToken(m[1]);
if (hi != null) return [0, hi];
}
// Over / above / greater-than / > X / X+
m = low.match(/^(?:over|above|greater than|>)\s*(.+)$/);
if (m) {
const lo = parseInchToken(m[1]);
if (lo != null) return [lo, Infinity];
}
m = text.match(/^(.+?)\+\s*$/);
if (m) {
const lo = parseInchToken(m[1]);
if (lo != null) return [lo, Infinity];
}
// Range: "A - B" (hyphen / en / em dash, quotes optional on either side)
const parts = text.split(/\s*[-–—]\s*/);
if (parts.length === 2) {
const lo = parseInchToken(parts[0]);
const hi = parseInchToken(parts[1]);
if (lo != null && hi != null && hi > lo) return [lo, hi];
}
// Single value band, treated as [v, v+ε) — rare
const only = parseInchToken(text);
if (only != null) return [only, only + 1e-9];
return null;
}
function boundsFromSlug(slug: string): [number, number] | null {
const s = slug.trim().toLowerCase();
if (!s) return null;
if (s === 'xthin' || s === 'under' || s === 'thin') return [0, 0.5];
const range = s.match(/^(\d+)-(\d+)qtr$/);
if (range) {
const lo = Number(range[1]) * 0.25;
const hi = Number(range[2]) * 0.25;
if (hi > lo) return [lo, hi];
}
const min = s.match(/^(\d+)qtr$/);
if (min) return [Number(min[1]) * 0.25, Infinity];
return null;
}
/**
* Pick the synced thickness term id for `thicknessIn`, or null when unknown
* (no thickness, no parseable bands, negative value).
*/
export function deriveThicknessBandId(
thicknessIn: number | null | undefined,
terms: ThicknessTerm[],
): number | null {
if (thicknessIn == null) return null;
const t = Number(thicknessIn);
if (!Number.isFinite(t) || t < 0) return null;
const scored: [number, number, number][] = [];
for (const term of terms) {
const b = termBounds(term);
if (!b) continue;
if (term.id == null) continue;
scored.push([b[0], b[1], Number(term.id)]);
}
if (!scored.length) return null;
// Prefer the tightest band that contains t under half-open [low, high).
const matches = scored.filter(([lo, hi]) => lo <= t && t < hi).map(([lo, hi, id]) => [hi - lo, lo, hi, id] as const);
if (matches.length) {
matches.sort((a, z) => a[0] - z[0]);
return Number(matches[0][3]);
}
// Gaps in the taxonomy: pick the nearest band by lower edge.
scored.sort((a, z) => a[0] - z[0]);
for (const [lo, hi, id] of scored) {
if (t < hi) return id;
}
return Number(scored[scored.length - 1][2]);
}
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { nextPrimary, selectedSummary } from './woodPrimary';
test('nextPrimary: empty selection clears primary', () => {
assert.equal(nextPrimary([], 21), '');
assert.equal(nextPrimary([], ''), '');
});
test('nextPrimary: default is first selected (REVIEW-15)', () => {
assert.equal(nextPrimary([21, 22], ''), 21);
assert.equal(nextPrimary([21, 22], null), 21);
});
test('nextPrimary: keeps current when still selected', () => {
assert.equal(nextPrimary([21, 22], 22), 22);
assert.equal(nextPrimary([22], 22), 22);
});
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
assert.equal(nextPrimary([22, 23], 21), 22);
assert.equal(nextPrimary([23], 21), 23);
});
test('selectedSummary: placeholder when empty', () => {
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
});
test('selectedSummary: shows names, then +N overflow', () => {
const m = new Map([
[1, 'Live Edge'],
[2, 'Book Match'],
[3, 'Cookie'],
]);
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
});
test('selectedSummary: falls back to count when names unknown', () => {
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
});
+32
View File
@@ -0,0 +1,32 @@
/**
* REVIEW-15: the owner declares one primary wood category among the selected
* set. Default primary = first selected. Deselecting the current primary
* promotes the first remaining selected id. Empty selection clears primary.
*/
export function nextPrimary(
selected: number[],
current: number | '' | null | undefined,
): number | '' {
if (!selected.length) return '';
const cur = typeof current === 'number' ? current : '';
if (cur !== '' && selected.includes(cur)) return cur;
return selected[0];
}
/** Closed MultiSelect summary: names when known, else a count. */
export function selectedSummary(
selected: number[],
nameById: Map<number, string>,
placeholder: string,
maxNames = 2,
): string {
if (!selected.length) return placeholder;
const names = selected
.map((id) => nameById.get(id))
.filter((n): n is string => !!n && n.length > 0);
if (!names.length) {
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
}
if (names.length <= maxNames) return names.join(', ');
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
}