Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ Matching fixes (2026-10-28, owner reports from the HORS panel: a Control Hub tha
- **Sizes and bores.** `spellSizes` turns "8x14x5mm" (or "8 × 14 × 5 mm") into "8mm 14mm 5mm" for library names and the tagged search; a configuration's own size field is ignored when the name gives sizes. `boreConflict` drops products with the other bore: "Round Bore" excludes goBILDA's REX/hex bore, and "REX" or "hex" excludes round ones.
- **Tagged exact match.** A vendor-tagged part ("(GB)") whose search leaves one product that carries every number in the name is an exact match ("8x14x5mm Bearing - Round Bore (GB)" is 1611-0514-0008; "6000rpm Motor - Bare REX (GB)" is 5203-2402-0001). Sizes written as one token must appear in the product name in the same order (bore, outside diameter, thickness), so "5x14x8mm" is never matched to the 8×14×5 bearing. Several products, or a name without numbers, stay a choice. A part carrying two discontinued numbers (a part number and a name for different products) gets every replacement as a choice.

Review flow (2026-10-28, owner feedback from the live HORS panel):
- **Unmatched Buy parts are in Review.** `needsProduct` (`app/model.ts`): Buy with no exact or confirmed product. `reviewQueue` lists them last as kind `product` (filter "No product"), each row offering what fixes it: Confirm/Not this for a possible product, "Use {vendor} {SKU}" for up to three of `productOptions` (possible match, `choices`, `nearMatches`), the existing vendor search link when the name or part number holds a SKU, and 3D print / Not needed / Other. `Summary` gains `unmatched` and `toReview` (= `review` + `unmatched`); the Review tab's count and dot, the Overview primary action ("Review N parts"), and its first next move all use `toReview`. The separate "unmatched" primary action and next move are gone; the type bar's orange share is now labelled "Undecided" (it still counts only `disposition === 'review'`, so the bar adds up to the unique count). The Buy list keeps its "No vendor match yet" group, so the order view still shows everything to buy, with a "Resolve in Review" button that opens Review on "No product"; Overview's "No vendor yet" row does the same. `applyMatchDecision` now also accepts a pick on a rejected line, since Review offers a rejected part's near matches.
- **Drawer moves on.** `open(key, list)` records the list a part was opened from (Review's filtered order, a Buy list group, a Make group, the Parts filter) and which parts still belong to it. After a type or product choice saves, the drawer shows the next part still in that list (`nextInList`, wrapping to skipped ones) with "{name} moved to 3D print" (`movedText`), or closes and shows that note as a page toast when none is left or the part was opened on its own (Overview search). A failed save stays on the part with its error. The drawer also lists an unmatched part's closest products ("Use this"). Overview search's Enter now blurs the field first; with focus left in the field, the drawer closed itself at once.
- **Bulk types.** Review rows have checkboxes (shift-click for a range, Space on the highlighted row, "Select all shown") and "Select all N in {subassembly}" (`subassemblyOf`: the part's direct parent by its first placement, as its path of names from the top without instance numbers, so every copy of one subassembly is one group and two unrelated subassemblies of the same name are not; `inSubassembly` matches any depth below it; shift-click ranges start from the last clicked row looked up by key). The bar sets one type through `setDispositions` in `useControlKit`: the existing per-part override save, four at a time, each with its own `saving` state and no list-wide lock; rows of one part save once, and a part whose type was changed after the batch began (drawer, row button) is skipped rather than overwritten. Failures stay selected with a "{n} of {m} could not be saved: names" alert.
- **Fastener counts.** Onshape's OpenAPI (`BTRootAssemblyInfo`, `BTSubAssemblyInfo`) documents `instances` as "including those created by patterns and replicates"; `patterns` only maps seeds to copies and `features` holds the Replicate feature. The walk already counts every instance, so pattern and Replicate copies were always counted (the HORS scan's BOM quantities matched the definition on all 102 matched rows). `fixtures/assembly-patterns.json` pins this, including a suppressed pattern copy. No extra Onshape request is needed; the "can be low with Replicate or patterns" notes in the Buy list, workbook, and classifier now say copies are counted and only fasteners never modelled are missing. Not verified against a live assembly with a Replicate feature.
- Screenshots of the flow at 380 and 1440 px, from fixture mode with the saved HORS scan, are in `docs/screenshots/review-flow/`.

## Tuning, tests, and release

Match normalization, thresholds, and alternative ranking are in `packages/shared/src/matching.ts`; an exact SKU still forces Buy absent a manual override. Disposition rules (`decideDisposition`), word lists, and print folders are in `packages/shared/src/classifier.ts`; order quantities are in `packages/shared/src/purchasing.ts`. Vendor stale hours and cron schedule are in `wrangler.toml`; catalog crawl batch limits are in `apps/worker/src/vendor-ingest.ts`. Scan request flow, source cache, and backoff are in `apps/worker/src/scan.ts`; BOM column mapping is in `packages/shared/src/bom.ts`; STEP export job bounds (per-call request budget, 5-minute translation timeout, 20 MB file cap) are in `apps/worker/src/step-export.ts`. Change these with fixture tests and record any changed data shape here.
Expand Down
48 changes: 37 additions & 11 deletions apps/web/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Boxes, Check, LayoutDashboard, ListChecks, LogOut, RefreshCw, Settings, ShoppingCart, Wrench } from 'lucide-react';
import { Alert } from '../components/arc/alert/alert';
import { Button } from './bits';
import { Progress } from '../components/arc/progress/progress';
import { contextKeys, useControlKit, type ControlKit, type ScanCounts, type ScanStep } from './useControlKit';
import { ago, summarize, type View } from './model';
import { ago, nextInList, summarize, type View } from './model';
import type { BomLine } from '@control-kit/shared';
import { Overview } from './Overview';
import { Review } from './Review';
import { Parts } from './Parts';
Expand All @@ -17,6 +18,11 @@ import { useControlPoint } from './useControlPoint';
import { WorkspaceMark } from './ControlPoint';
import { CONTROL_POINT_CAD_URL } from '@control-kit/shared';

/** The list a part was opened from, in its order, and whether a part still belongs to it (a decided part leaves Review). */
export type PartList = { keys: string[]; listed?: (line: BomLine) => boolean };
export type Open = (key: string, list?: PartList) => void;
export type ReviewFilter = 'all' | 'match' | 'suggested' | 'undecided' | 'product' | 'ai';

const views: View[] = ['overview', 'review', 'parts', 'buy', 'make'];
const viewLabel: Record<View, string> = { overview: 'Overview', review: 'Review', parts: 'Parts', buy: 'Buy list', make: 'Make' };
const viewIcon: Record<View, ReactNode> = { overview: <LayoutDashboard aria-hidden />, review: <ListChecks aria-hidden />, parts: <Boxes aria-hidden />, buy: <ShoppingCart aria-hidden />, make: <Wrench aria-hidden /> };
Expand Down Expand Up @@ -109,7 +115,14 @@ function Workspace({ kit }: { kit: ControlKit }) {
const result = kit.result!;
const cp = useControlPoint(kit);
const [view, setView] = useState<View>(() => (views as string[]).includes(location.hash.slice(1)) ? location.hash.slice(1) as View : 'overview');
const [selected, setSelected] = useState<string | null>(null);
// The open part, and the list it was opened from: after a choice the drawer moves to the next part still in that list.
const [selected, setSelected] = useState<{ key: string; list: PartList | null } | null>(null);
/** "Bracket moved to 3D print": shown in the drawer when it moves on, or on the page when it closes. */
const [notice, setNotice] = useState<{ text: string; at: number } | null>(null);
useEffect(() => { if (!notice) return; const timer = setTimeout(() => setNotice(null), 5000); return () => clearTimeout(timer); }, [notice]);
const [reviewFilter, setReviewFilter] = useState<ReviewFilter>('all');
const latest = useRef(result);
latest.current = result;
const [settingsOpen, setSettingsOpen] = useState(false);
// Relative times need a clock, which the first render does not have.
const [now, setNow] = useState<number | null>(null);
Expand All @@ -121,12 +134,24 @@ function Workspace({ kit }: { kit: ControlKit }) {
document.querySelector('.tab[aria-current="page"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}, [view]);
const summary = summarize(result);
const counts: Record<View, number | null> = { overview: null, review: summary.review, parts: summary.uniqueLines, buy: summary.buy, make: summary.make.total };
const counts: Record<View, number | null> = { overview: null, review: summary.toReview, parts: summary.uniqueLines, buy: summary.buy, make: summary.make.total };
// Each section's colour job: review is orange while parts wait and green once none do; buy is sky; make is gold.
const tone: Record<View, string | null> = { overview: null, review: summary.review ? 'attention' : 'done', parts: null, buy: 'buy', make: 'make' };
const tone: Record<View, string | null> = { overview: null, review: summary.toReview ? 'attention' : 'done', parts: null, buy: 'buy', make: 'make' };
const dot = (item: View) => tone[item] && <span className={`dot ${tone[item]}`} aria-hidden />;
const line = selected ? result.lines.find(item => item.bomLineKey === selected) ?? result.suppressed.find(item => item.bomLineKey === selected) ?? null : null;
const open = (key: string) => setSelected(key);
const line = selected ? result.lines.find(item => item.bomLineKey === selected.key) ?? result.suppressed.find(item => item.bomLineKey === selected.key) ?? null : null;
const open: Open = (key, list) => { setNotice(null); setSelected({ key, list: list ?? null }); };
/** After a saved choice: the next part still in the list the drawer came from, or close. The scan read is the latest. */
const moved = (from: BomLine, text: string) => {
setNotice({ text, at: Date.now() });
setSelected(current => {
if (!current || current.key !== from.bomLineKey) return current;
const list = current.list;
const lines = new Map(latest.current.lines.map(item => [item.bomLineKey, item]));
const next = list && nextInList(list.keys, current.key, key => { const item = lines.get(key); return Boolean(item) && (!list.listed || list.listed(item!)); });
return next ? { key: next, list } : null;
});
};
const goReview = (filter: ReviewFilter) => { setReviewFilter(filter); setView('review'); };
const subtitle = [result.documentName, now ? `scanned ${ago(result.scannedAt, now)}` : null].filter(Boolean).join(' · ');

return <div className="shell">
Expand Down Expand Up @@ -163,14 +188,15 @@ function Workspace({ kit }: { kit: ControlKit }) {
<main className="content">
{kit.error && <Alert tone="danger" title={kit.error} onDismiss={kit.clearError} />}
{kit.progress && <section className="panel"><div className="panel-body" style={{ paddingTop: 16 }}><ScanProgress kit={kit} /></div></section>}
{view === 'overview' && <Overview kit={kit} cp={cp} summary={summary} go={setView} open={open} now={now} />}
{view === 'review' && <Review kit={kit} open={open} />}
{view === 'overview' && <Overview kit={kit} cp={cp} summary={summary} go={setView} goReview={goReview} open={open} now={now} />}
{view === 'review' && <Review kit={kit} open={open} filter={reviewFilter} setFilter={setReviewFilter} />}
{view === 'parts' && <Parts kit={kit} open={open} />}
{view === 'buy' && <BuyList kit={kit} open={open} go={setView} />}
{view === 'buy' && <BuyList kit={kit} open={open} go={setView} goReview={goReview} />}
{view === 'make' && <Make kit={kit} open={open} go={setView} />}
</main>
</div>
<PartDrawer kit={kit} line={line} onClose={() => setSelected(null)} />
<PartDrawer kit={kit} line={line} onClose={() => setSelected(null)} onMoved={moved} notice={notice && selected ? notice.text : null} hasNext={Boolean(selected?.list)} />
{notice && !selected && <div className="toast" role="status" key={notice.at}>{notice.text}</div>}
<SettingsDrawer kit={kit} cp={cp} open={settingsOpen} onOpenChange={setSettingsOpen} />
</div>;
}
21 changes: 13 additions & 8 deletions apps/web/src/app/BuyList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import { ExternalLink, FileSpreadsheet, ShoppingCart } from 'lucide-react';
import { Alert } from '../components/arc/alert/alert';
import { EmptyState } from '../components/arc/empty-state/empty-state';
import type { ControlKit } from './useControlKit';
import { buyList, catalogsComplete, fasteners, feedDates, money, priceDate, summarize, type View } from './model';
import type { BomLine } from '@control-kit/shared';
import { buyList, catalogsComplete, needsProduct, fasteners, feedDates, money, priceDate, summarize, type View } from './model';
import { Button, LineCost, SectionHead, StockNote } from './bits';
import type { Open, ReviewFilter } from './App';

/**
* What to order, by vendor. Only exact or confirmed matches sit under a vendor, and only priced lines count toward a
* total; parts to buy without a match are listed below with what they still need.
*/
export function BuyList({ kit, open, go }: { kit: ControlKit; open: (key: string) => void; go: (view: View) => void }) {
export function BuyList({ kit, open, go, goReview }: { kit: ControlKit; open: Open; go: (view: View) => void; goReview: (filter: ReviewFilter) => void }) {
const result = kit.result!;
const { vendors, needsSku } = buyList(result);
const stock = fasteners(result);
const summary = summarize(result);
const complete = summary.buyLines > 0 && summary.pricedLines === summary.buyLines;
const outOfStock = vendors.flatMap(group => group.lines).filter(line => line.vendor?.stock === 'out').length;
// The drawer steps through the group it was opened from while its parts are still in that group.
const inGroup = (lines: BomLine[], listed: (line: BomLine) => boolean) => (key: string) => open(key, { keys: lines.map(line => line.bomLineKey), listed });

return <>
<SectionHead icon={<ShoppingCart />} tone="buy" title="Buy list" actions={<>
Expand Down Expand Up @@ -45,7 +49,7 @@ export function BuyList({ kit, open, go }: { kit: ControlKit; open: (key: string
const vendor = line.vendor!;
return <div key={line.bomLineKey} className="row">
<div className="row-main">
<button type="button" className="row-title link-button" onClick={() => open(line.bomLineKey)}><span>{line.name}</span></button>
<button type="button" className="row-title link-button" onClick={() => inGroup(group.lines, item => item.disposition === 'buy' && !needsProduct(item))(line.bomLineKey)}><span>{line.name}</span></button>
<span className="row-meta">
<a className="link num" href={vendor.url} target="_blank" rel="noopener noreferrer">{vendor.sku} <ExternalLink aria-hidden size={11} /></a>
<span><span className="num">{line.qty}</span> needed</span>
Expand All @@ -64,19 +68,20 @@ export function BuyList({ kit, open, go }: { kit: ControlKit; open: (key: string
</section>)}

{needsSku.length > 0 && <section className="panel tone-attention" aria-labelledby="needs-sku">
<div className="panel-head"><div><h2 id="needs-sku"><span className="dot attention" aria-hidden /> No vendor match yet</h2><p>{catalogsComplete(result.catalogHealth) ? 'These parts are marked Buy but no catalog product matched. Add the vendor SKU to the part number in Onshape and rescan.' : 'Some vendor catalogs are still loading. Rescan later, or add the vendor SKU to the part number in Onshape.'}</p></div></div>
<div className="rows">{needsSku.map(line => <button key={line.bomLineKey} type="button" className="row" onClick={() => open(line.bomLineKey)}>
<div className="panel-head"><div><h2 id="needs-sku"><span className="dot attention" aria-hidden /> No vendor match yet</h2><p>{catalogsComplete(result.catalogHealth) ? 'These parts are marked Buy but no catalog product matched, so they are also in Review. Pick a product there, or add the vendor SKU to the part number in Onshape and rescan.' : 'Some vendor catalogs are still loading. These parts are also in Review; rescan later, or add the vendor SKU to the part number in Onshape.'}</p></div>
<Button size="sm" variant="secondary" onClick={() => goReview('product')}>Resolve in Review</Button></div>
<div className="rows">{needsSku.map(line => <button key={line.bomLineKey} type="button" className="row" onClick={() => inGroup(needsSku, needsProduct)(line.bomLineKey)}>
<span className="row-main"><span className="row-title"><span>{line.name}</span></span>
<span className="row-meta"><span><span className="num">{line.qty}</span> needed</span>{line.partNumber ? <span className="num">{line.partNumber}</span> : <span>no part number</span>}{line.matchStatus === 'possible' && <span>possible match to confirm</span>}</span></span>
<span className="row-aside muted small">Details</span>
</button>)}</div>
</section>}

{stock.length > 0 && <section className="panel" aria-labelledby="fasteners">
<div className="panel-head"><div><h2 id="fasteners">Fasteners</h2><p>Screws, nuts, washers, and clips usually come from your hardware stock, so they need no decision. Counts come from the CAD and can be low when fasteners were placed with Replicate or patterns.</p></div></div>
<div className="rows">{stock.map(line => <button key={line.bomLineKey} type="button" className="row" onClick={() => open(line.bomLineKey)}>
<div className="panel-head"><div><h2 id="fasteners">Fasteners</h2><p>Screws, nuts, washers, and clips usually come from your hardware stock, so they need no decision. Counts are every placement in the CAD, including copies placed by patterns and Replicate; fasteners that were never modelled are not counted.</p></div></div>
<div className="rows">{stock.map(line => <button key={line.bomLineKey} type="button" className="row" onClick={() => inGroup(stock, item => item.disposition === 'hardware')(line.bomLineKey)}>
<span className="row-main"><span className="row-title"><span>{line.name}</span></span>
<span className="row-meta"><span>about <span className="num">{line.qty}</span> in the CAD</span>{line.partNumber && <span className="num">{line.partNumber}</span>}</span></span>
<span className="row-meta"><span><span className="num">{line.qty}</span> in the CAD</span>{line.partNumber && <span className="num">{line.partNumber}</span>}</span></span>
<span className="row-aside muted small">Details</span>
</button>)}</div>
</section>}
Expand Down
Loading
Loading