From 8bf3e3b705d4eb6ea9b682c1142658ea00e3616c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 11 Sep 2026 23:27:29 +0100 Subject: [PATCH] Simplify --- README.md | 51 +++-- benchmarks/README.md | 59 +++-- benchmarks/cases.ts | 2 - e2e/landing.spec.ts | 5 +- e2e/optimisation.spec.ts | 8 +- e2e/planner.spec.ts | 166 ++++++++++---- e2e/usability.spec.ts | 5 + src/App.tsx | 284 ++++++++---------------- src/components/CutDialog.tsx | 12 +- src/components/CutList.tsx | 80 +++---- src/components/Fields.tsx | 51 ++--- src/components/FloorPlan.tsx | 194 +++++++++------- src/components/MaterialsEditor.tsx | 31 +-- src/components/OutlineEditor.tsx | 24 +- src/components/PrintPlan.tsx | 32 ++- src/lib/board-tracking.ts | 95 ++++---- src/lib/cut-list.ts | 58 +++-- src/lib/cut-preview.ts | 37 +--- src/lib/defaults.ts | 3 +- src/lib/dimension-labels.ts | 12 +- src/lib/drawing-geometry.ts | 34 +++ src/lib/flooring.ts | 159 ++----------- src/lib/legacy-query.ts | 109 +++++++++ src/lib/piece-labels.ts | 15 +- src/lib/planning.ts | 49 ++++ src/lib/project-state.ts | 162 ++++++++++++++ src/lib/skirting.ts | 58 ++--- src/lib/types.ts | 19 +- src/lib/url-state.ts | 215 +++++------------- src/lib/use-printing.ts | 22 ++ src/lib/use-project.ts | 125 +++++------ src/styles.css | 48 ++-- tests/board-tracking.test.ts | 167 ++++---------- tests/cut-list.test.ts | 2 +- tests/cut-preview.test.ts | 71 +----- tests/optimisation.test.ts | 33 +-- tests/project-state.test.ts | 99 +++++++++ tests/skirting.test.ts | 22 +- tests/url-state.test.ts | 344 +++++++++++------------------ tsconfig.json | 2 +- 40 files changed, 1455 insertions(+), 1509 deletions(-) create mode 100644 src/lib/drawing-geometry.ts create mode 100644 src/lib/legacy-query.ts create mode 100644 src/lib/planning.ts create mode 100644 src/lib/project-state.ts create mode 100644 src/lib/use-printing.ts create mode 100644 tests/project-state.test.ts diff --git a/README.md b/README.md index 3a0accd..ed6bad2 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ npm run benchmark -- --random # Compare appearance-first optimisation with the s New plans start with a random laying pattern. Click the numbered room, materials and cut-plan headings to expand or collapse each panel. Click **Custom** to build an outline wall by wall; guidance below the shape choices explains how to begin. Advanced flooring and skirting settings expand separately when needed. Saved plans keep their chosen laying pattern. -1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline. Insert a wall after any existing wall, drag its handle to reorder it, or use the up/down buttons on a phone or keyboard. Doors stay attached to their wall. Numeric fields allow empty values and a minus sign while typing; unfinished entries revert to the last valid value when you leave the field. +1. Start with a rectangle, fireplace recess, or custom outline. Enter wall lengths in order. The first wall runs east; the turn **after** each wall is positive for right and negative for left. For example, a clockwise rectangle has 90° turns. The final corner is derived from the closed outline. Insert a wall after any existing wall, drag its handle to reorder it, or use the up/down buttons on a phone or keyboard. Doors stay attached to their wall. Numeric fields keep a local draft while typing. Blur or Enter commits a number; Escape discards it. Empty or unfinished entries revert to the last committed value. 2. Add door openings with a width and a distance from the named starting corner. Each doorway shows the neighbouring wall names and an arrow in the measurement direction. Include architrave/trim in the width where skirting must be omitted. Doors terminate skirting; laminate ends at the room outline with a transition and expansion gap. Rooms are planned independently. 3. For a custom outline, the closing-wall suggestion gives the required length and turn. Add it, then replace the suggested length with the actual measured length. An outline must close within the chosen tolerance and have no intersections or invalid doors before a cut plan can be produced. 4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room. -5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. The **Floorboards / Skirting** switch above the drawing shows one material at a time, with the whole drawing fitting without an internal scrollbar. The choice is saved as `view=flooring` or `view=skirting`. Locating or searching for a cut switches to its material automatically; printing includes both materials. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall. -6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it. -7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable version 2 query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. The same parameters are saved automatically in localStorage under `floor-planner.project`. Opening the app without URL parameters restores the last saved plan; an explicit project query takes precedence and becomes the saved plan. If localStorage is unavailable or full, URL saving still works. There is no account, API or database. Fonts are system fonts; the app makes no external font requests. +5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. The **Floorboards / Skirting** switch above the drawing shows one material at a time, with the whole drawing fitting without an internal scrollbar. The material choice is saved with the plan. Locating or searching for a cut switches to its material automatically; printing includes both materials. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall. +6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A **Search active** or **Location active** banner stays above the pinned drawing; **Show all pieces** clears it. Typed search text and explicit board locations are saved separately. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it. +7. Copy the link to save or share the complete project. A version 3 JSON payload in `?plan=...` stores measurements, materials, completion marks and view choices. Piece selections include their material, so flooring F1 and a later room’s skirting F1 open the correct preview. Existing version 2 query links still open through a compatibility importer and are saved in the new format. Reloading or opening the link reproduces the plan. Browser back/forward restores room changes. The same link is saved automatically in localStorage under `floor-planner.project`. Opening the app without a project query restores the last saved plan; an explicit project query takes precedence. If localStorage is unavailable or full, URL saving still works. There is no account, API or database. Fonts are system fonts; the app makes no external font requests. ## Calculation rules and practical limits @@ -46,28 +46,35 @@ New plans start with a random laying pattern. Click the numbered room, materials - Offcut reuse preserves the needed left/right factory ends. Only full-width end remnants are available for reuse; ripped side strips and shaped scraps are conservatively waste. Crosscuts include kerf. The stock count is actual allocated boards, not an area-only estimate. Waste includes kerf and unused material, including still-usable stock remainders. - Skirting internal corners use coped joints. The blank keeps the wall-corner long point instead of subtracting the receiving board’s thickness; the cope preparation is half the measured turn. Earlier walls receive adjoining later walls, so installation order has no cycle. At square corners, a 4,000 mm wall keeps a 4,000 mm blank; with 18 mm skirting at both corners its exposed front length is 3,964 mm. Non-square receiving ends have a wall-fitting bevel. External corners use equal mitres. Minimum sizes check the exposed face and installed body after corner and door allowances. Preparation previews and dashed body edges guide marking; the actual moulding profile must be scribed and back-cut, since thickness alone does not define it. This follows [Gary Katz’s coping and long-point measurement guidance](https://www.finehomebuilding.com/pdf/021174063.pdf); the general-angle allowances are derived from intersecting wall faces. - Skirting cut lengths are the longest-point envelope. Cut square blanks first, then prepare the mitres and cope the indicated internal ends; this keeps reusable stock remainders square. Back lengths and diagram positions locate the wall-facing edge. The stock allocation includes crosscut kerf and counts additional bevel and coping operations. -- Random flooring offers **Minimise cuts** and **Maximise randomness**, with the actual cut count and waste percentage beside each choice. Minimise cuts ranks saw passes first, then simpler cuts (fewer shaping passes, then fewer rips), visual repetition and finally purchased boards. Maximise randomness ranks visual repetition first, then the same cutting and waste priorities. The choice is saved as `flooring.optimisation=cuts` or `flooring.optimisation=random`; changing it clears completion marks because stock allocation can change. Brick prioritises cutting effort and then waste. -- Flooring uses a bounded beam search that preserves factory-ended offcuts for later exact fits. Completed candidates are repacked, and interchangeable full-width rectangular rows can be reordered without adding cuts. Both options share clipping results, and the best completed candidates are compared before displaying totals. Skirting compares full-stock, balanced and offcut-aware splits, including exact three-piece stock combinations, preferring fewer cuts and then less waste. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order remains an input. See [the benchmark definitions and iteration results](benchmarks/README.md). +- Random flooring offers **Minimise cuts** and **Maximise randomness**, with the actual cut count and waste percentage beside each choice. Minimise cuts ranks saw passes first, then simpler cuts (fewer shaping passes, then fewer rips), visual repetition and finally purchased boards. Maximise randomness ranks visual repetition first, then the same cutting and waste priorities. The choice is saved with the project; changing it clears completion marks because stock allocation can change. Brick prioritises cutting effort and then waste. +- Flooring uses a bounded beam search that preserves factory-ended offcuts for later exact fits. Completed candidates are repacked. Both options share clipping results, and the best completed candidates are compared before displaying totals. Skirting compares full-stock, balanced and offcut-aware splits, packed in decreasing length order, preferring fewer cuts and then less waste. Available offcuts can move the next wall’s joints. Minimum lengths are reserved on the exposed face and full-thickness body of angled and coped pieces. These are deterministic, auditable heuristics rather than guarantees of a global minimum. Room order remains an input. See [the benchmark definitions and iteration results](benchmarks/README.md). - Board counts exclude an extra allowance for damage or mistakes. Spacer, minimum size and staggering defaults are editable starting values; follow the exact product's installation instructions. For example, [Quick-Step's installation guidance](https://www.quick-step.co.uk/en-gb/laminate/installation) provides product-specific manuals, and its [room-transition guidance](https://www.quick-step.co.uk/en-gb/frequently-asked-questions/laminate/preparation-and-installation/do-i-really-need-expansion-gaps-between-every-room) explains separate expansion joints. - Links are bounded to 20 rooms, 100 walls per room and 30 doors per wall. Flooring calculations are bounded to 2,000 purchased boards and 500 rows per room. Large plans may take longer; very large projects should be split into separate links. Long links may exceed the limits of a messaging service. ## Code map -| File | Responsibility | -| --------------------------- | ---------------------------------------------------------------- | -| `src/lib/types.ts` | Inputs and derived result contracts | -| `src/lib/defaults.ts` | Starter measurements and deterministic IDs | -| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets | -| `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation | -| `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation | -| `src/lib/url-state.ts` | Readable query schema and input validation | -| `src/lib/limits.ts` | Shared project, persistence and calculation bounds | -| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces | -| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search | -| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs | -| `src/lib/use-project.ts` | URL, localStorage and browser-history synchronization | -| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets | -| `tests/` | Pure calculation and state tests | -| `e2e/` | Actual browser workflows at desktop and mobile sizes | +| File | Responsibility | +| ----------------------------- | --------------------------------------------------------------------- | +| `src/lib/types.ts` | Inputs and derived result contracts | +| `src/lib/defaults.ts` | Starter measurements and deterministic IDs | +| `src/lib/geometry.ts` | Vector tracing, closure, validation and polygon offsets | +| `src/lib/flooring.ts` | Row search, clipping, factory-end-aware stock allocation | +| `src/lib/skirting.ts` | Corner allowances, run splitting and stock allocation | +| `src/lib/url-state.ts` | Versioned payload encoding and import validation | +| `src/lib/legacy-query.ts` | Read-only importer for existing version 2 links | +| `src/lib/project-state.ts` | Edit actions and allocation invalidation | +| `src/lib/planning.ts` | Reuse geometry and plans when their calculation inputs stay unchanged | +| `src/lib/drawing-geometry.ts` | Shared bounds and collision primitives for drawing labels | +| `src/lib/use-printing.ts` | Mount print sheets on native print events and release them afterward | +| `src/lib/limits.ts` | Shared project, persistence and calculation bounds | +| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces | +| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search | +| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs | +| `src/lib/use-project.ts` | URL, localStorage and browser-history synchronization | +| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets | +| `tests/` | Pure calculation and state tests | +| `e2e/` | Actual browser workflows at desktop and mobile sizes | Only the inputs, completion marks and view selections are persisted. Incomplete numeric drafts exist only while editing a field. Geometry, plans, quantities, warning messages and drawings are derived. There are no timestamps or random-number calls in the planners. `npm run build` also checks unused variables and strict TypeScript types. + +Room renames and completion marks reuse existing plans. Geometry is built once per changed outline and shared by both material planners. Cut groups and counts are derived once for the screen and print views. Printed sheets mount only when printing begins, including native keyboard printing. diff --git a/benchmarks/README.md b/benchmarks/README.md index 6fd9711..ccb36f4 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -41,22 +41,22 @@ Totals across the same 84 cases, September 2026: | Metric | Original | Minimise cuts | Maximise randomness | | ------------------- | -------: | ------------: | ------------------: | -| Flooring saw passes | 2,177 | **1,741** | **2,113** | +| Flooring saw passes | 2,177 | **1,741** | **2,122** | | Shaping passes | 192 | 184 | 188 | -| Rip passes | 414 | 402 | 409 | -| Legacy penalty | 265 | 2,207 | 346 | -| Flooring boards | 3,561 | 3,548 | 3,551 | -| Flooring waste | 8.20% | 7.85% | 7.94% | +| Rip passes | 414 | 402 | 411 | +| Legacy penalty | 265 | 2,279 | 350 | +| Flooring boards | 3,561 | 3,548 | 3,550 | +| Flooring waste | 8.20% | 7.85% | 7.91% | | Invalid cases | 0 | 0 | 0 | -Minimise cuts saves **20.0%** of saw passes. Maximise randomness saves **2.9%** of saw +Minimise cuts saves **20.0%** of saw passes. Maximise randomness saves **2.5%** of saw passes against the original planner. For random flooring in the default 4,800 × 3,600 mm room, 38 passes become **24** or **31**, respectively; both options use 76 boards and have approximately 1.2% waste. -Skirting drops from **1,447 to 1,436 passes**. It uses 521 boards instead of 513: -cutting effort now takes precedence over waste, which rises from 9.28% to 10.68%. -Eight cases improve and 76 tie. +Skirting drops from **1,447 to 1,437 passes**. It uses 522 boards instead of 513: +cutting effort now takes precedence over waste, which rises from 9.28% to 10.85%. +Seven cases improve and 77 tie. For flooring, 78 cut-priority cases improve and six tie against the original planner. The bounded search does not establish global optimality. @@ -70,14 +70,14 @@ numbers. A final bit-mixing step now spreads those candidates across the allowed | Metric | Cuts before | Cuts now | Random before | Random now | | ------------------------- | ----------: | -------: | ------------: | ---------: | -| Near-H row pairs | 515 | 247 | 557 | **6** | -| Saw passes | 1,741 | 1,741 | 1,994 | 2,113 | -| Boards | 3,537 | 3,548 | 3,579 | 3,551 | -| Waste | 7.57% | 7.85% | 8.66% | 7.94% | +| Near-H row pairs | 515 | 269 | 557 | **6** | +| Saw passes | 1,741 | 1,741 | 1,994 | 2,122 | +| Boards | 3,537 | 3,548 | 3,579 | 3,550 | +| Waste | 7.57% | 7.85% | 8.66% | 7.91% | | Default-room near-H pairs | 17 | 17 | 11 | **0** | | Default-room saw passes | 24 | 24 | 30 | 31 | -Appearance priority removes **98.9%** of near-H pairs for **6.0%** more cuts than the +Appearance priority removes **98.9%** of near-H pairs for **6.4%** more cuts than the previous iteration. All six remaining pairs are in `forced-alternation`, whose 600 mm minimum pieces force alternating starts. It improves 79 cases and ties five under the corrected objective. Ten additional room seeds also produce zero near-H pairs. @@ -96,19 +96,15 @@ this beside the laying-pattern choice. Both frozen baselines remain unchanged. searching offcuts for pieces requiring both factory ends. 3. Repack completed boundary blanks, reserving exact complementary factory ends first. Preserve the original placement and cutting order within each source board. -4. Try bounded swaps of interchangeable rectangular rows. Accept only a lower - pattern penalty with unchanged cuts, stock and geometry coverage. -5. Polish all surviving completed candidates, retaining the best result. -6. Preserve end remnants during search instead of trimming them immediately. +4. Polish all surviving completed candidates, retaining the best result. +5. Preserve end remnants during search instead of trimming them immediately. This alone improves 56 cut-priority cases and saves another 99 passes after the earlier improvements. Final packing can still combine non-exact blanks. -7. When repetition remains, compare an immediate-reuse search as well. This improves +6. When repetition remains, compare an immediate-reuse search as well. This improves five appearance-priority cases. All searches share their clipping cache, and the two UI choices select from the completed results. Selection itself does not rerun the optimiser. -8. Compare exact three-piece skirting fills with the original packing. This improves - two cases for a small additional calculation cost. -9. Add the board-scale H penalty and mix the offset hash. Search size and geometry +7. Add the board-scale H penalty and mix the offset hash. Search size and geometry constraints stay unchanged. Before/after drawings were checked for the default rectangle, fireplace and large floor, alongside the numeric metrics. @@ -130,3 +126,22 @@ without an error; temporary stock is repacked before enforcing the board limit. Tests enforce aggregate cutting budgets, broad H avoidance and physical invariants. The original metrics and cases remain fixed; the new H metric has its own frozen baseline so the correction does not rewrite the earlier results. + +## Simplification tradeoffs + +Removing row-swap polishing preserves all 1,741 cuts-first passes. Across the 84 +cases, appearance-first passes change from 2,113 to 2,122 (+0.43%), boards from 3,551 +to 3,550, and the legacy penalty from 346 to 350. Cuts-first near-H pairs change +from 247 to 269; appearance-first remains at six, all in the forced-alternation case. +Removing the exact three-piece skirting packing variant changes 1,436 passes to +1,437 and 521 boards to 522. Both frozen historical baselines remain unchanged. + +The conditional immediate-reuse search stays: removing it introduced a near-H pair +outside the forced-alternation case. Distinct room-label and cut-preview placement +strategies also stay; their bounds and collision primitives are shared. There is +not enough evidence to replace these strategies without losing useful behavior. + +Tests retain physical invariants, the two option priorities, near-H avoidance, +and aggregate budgets relative to the original baseline (at least 19% fewer +cuts-first passes; appearance-first and skirting use fewer passes than originally). +They no longer freeze the exact totals of a particular heuristic iteration. diff --git a/benchmarks/cases.ts b/benchmarks/cases.ts index 0ad1aac..7493bd6 100644 --- a/benchmarks/cases.ts +++ b/benchmarks/cases.ts @@ -58,7 +58,6 @@ export function plannerCases(): PlannerCase[] { name: 'angled', project: { ...angled.project, - version: defaultProject().version, flooring: { ...defaultProject().flooring, ...angled.project.flooring }, } as Project, }, @@ -66,7 +65,6 @@ export function plannerCases(): PlannerCase[] { name: 'angled-f8', project: { ...angledF8.project, - version: defaultProject().version, flooring: { ...defaultProject().flooring, ...angledF8.project.flooring }, } as Project, }, diff --git a/e2e/landing.spec.ts b/e2e/landing.spec.ts index 6c5f8a4..bd03b8a 100644 --- a/e2e/landing.spec.ts +++ b/e2e/landing.spec.ts @@ -43,8 +43,9 @@ for (const [name, selector] of [ await page.locator(selector).locator('a[href="?planner"]').click(); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800'); - await expect(page).toHaveURL(/\?version=\d+&/); + await expect(page).toHaveURL(/\?plan=/); await page.getByLabel('Room name', { exact: true }).fill('My first floor'); + await page.getByLabel('Room name', { exact: true }).blur(); const projectUrl = page.url(); await page.reload(); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor'); @@ -59,7 +60,9 @@ for (const [name, selector] of [ test('returning through the home page resumes the saved project', async ({ page }) => { await page.goto('/?planner'); await page.getByLabel('Room name', { exact: true }).fill('Weekend project'); + await page.getByLabel('Room name', { exact: true }).blur(); await page.getByLabel('Room width', { exact: true }).fill('4200'); + await page.getByLabel('Room width', { exact: true }).blur(); await page.getByRole('link', { name: 'Floor planner home' }).click(); await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor'); await page.getByRole('link', { name: 'Plan my floor' }).first().click(); diff --git a/e2e/optimisation.spec.ts b/e2e/optimisation.spec.ts index ddcc9e7..b9d4735 100644 --- a/e2e/optimisation.spec.ts +++ b/e2e/optimisation.spec.ts @@ -27,12 +27,14 @@ test('random optimisation choices show actual totals, select the plan and surviv expect(options.cuts.cuts).toBeLessThan(options.random.cuts); const checkbox = page.locator('.stock-cut-group input[type="checkbox"]').first(); await checkbox.check(); - expect(new URL(page.url()).searchParams.getAll('done')).not.toEqual([]); + expect(decodeState(new URL(page.url()).search).state.project.completedBoards).not.toEqual([]); await random.click(); await expect(random).toHaveAttribute('aria-pressed', 'true'); await expect(cuts).toHaveAttribute('aria-pressed', 'false'); - expect(new URL(page.url()).searchParams.get('flooring.optimisation')).toBe('random'); - expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]); + expect(decodeState(new URL(page.url()).search).state.project.flooring.optimisation).toBe( + 'random', + ); + expect(decodeState(new URL(page.url()).search).state.project.completedBoards).toEqual([]); const drawn = page.getByRole('region', { name: 'Floor plan preview' }).locator('.floor-piece'); await expect(drawn).toHaveCount(options.random.pieces.length); const ids = await drawn.evaluateAll((pieces) => diff --git a/e2e/planner.spec.ts b/e2e/planner.spec.ts index f590e38..37dde26 100644 --- a/e2e/planner.spec.ts +++ b/e2e/planner.spec.ts @@ -88,7 +88,7 @@ async function wallDimensionsInsideSvg(page: Page, wallNumber: number) { .filter({ hasText: new RegExp(`W${wallNumber} ·`) }); await expect(wall.locator('.wall-label text')).toBeVisible(); const result = await wall.evaluate((element) => { - const svg = element.ownerSVGElement!; + const svg = (element as SVGElement).ownerSVGElement!; const box = svg.viewBox.baseVal; const inverse = svg.getScreenCTM()!.inverse(); const rootPoint = (element: SVGGraphicsElement, x: number, y: number) => @@ -109,7 +109,6 @@ async function wallDimensionsInsideSvg(page: Page, wallNumber: number) { rootPoint(label, bounds.x, bounds.y + bounds.height), ]; return { - lineCount: lines.length, outside: [...endpoints, ...corners] .filter( (point) => @@ -121,7 +120,6 @@ async function wallDimensionsInsideSvg(page: Page, wallNumber: number) { .map((point) => ({ x: point.x, y: point.y })), }; }); - expect(result.lineCount).toBe(4); expect( result.outside, `W${wallNumber} lines, ticks and label must fit inside the SVG viewBox`, @@ -266,7 +264,7 @@ test.beforeEach(async ({ page }) => { page.on('pageerror', (error) => errors.get(page)!.push(error.message)); await page.goto('/?planner'); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible(); - await expect(page).toHaveURL(/\?version=2&/); + await expect(page).toHaveURL(/\?plan=/); }); test.afterEach(async ({ page }) => { @@ -405,10 +403,12 @@ test('inconsistent final measurements block cuts and small residuals show the ex }) => { await openWalls(page); await page.getByLabel('Wall 4 length', { exact: true }).fill('3700'); + await page.getByLabel('Wall 4 length', { exact: true }).blur(); await expect(page.getByText(/The outline is open by 100\.0 mm/).first()).toBeVisible(); await expect(floorEntries(page)).toHaveCount(0); await expect(skirtEntries(page)).toHaveCount(0); await page.getByLabel('Wall 4 length', { exact: true }).fill('3605'); + await page.getByLabel('Wall 4 length', { exact: true }).blur(); await expect(page.getByText(/non-straight wall/)).toBeVisible(); await expect(page.getByText('Final wall: 3,600 mm expected · 5 mm difference')).toBeVisible(); expect(await floorEntries(page).count()).toBeGreaterThan(0); @@ -417,9 +417,11 @@ test('inconsistent final measurements block cuts and small residuals show the ex test('room warnings stay with their room even when two room names match', async ({ page }) => { await openWalls(page); await page.getByLabel('Wall 4 length', { exact: true }).fill('3605'); + await page.getByLabel('Wall 4 length', { exact: true }).blur(); await expect(page.getByText(/non-straight wall/).first()).toBeVisible(); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await page.getByLabel('Room name', { exact: true }).fill('Living room'); + await page.getByLabel('Room name', { exact: true }).blur(); expect(state(page).project.rooms.map((room) => room.name)).toEqual([ 'Living room', 'Living room', @@ -429,6 +431,7 @@ test('room warnings stay with their room even when two room names match', async await expect(page.getByText(/non-straight wall/).first()).toBeVisible(); await openWalls(page); await page.getByLabel('Wall 4 length', { exact: true }).fill('3700'); + await page.getByLabel('Wall 4 length', { exact: true }).blur(); await expect(page.getByText(/outline is open by 100\.0 mm/).first()).toBeVisible(); await page.locator('.room-tab').nth(1).click(); await expect(page.getByText(/outline is open by 100\.0 mm/)).toHaveCount(0); @@ -440,14 +443,17 @@ test('custom walls close using a calculated wall that can be measured again', as await page.getByRole('button', { name: 'Custom', exact: true }).click(); await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); await page.getByLabel('Wall 2 length', { exact: true }).fill('3000'); + await page.getByLabel('Wall 2 length', { exact: true }).blur(); await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); await page.getByLabel('Wall 3 length', { exact: true }).fill('4000'); + await page.getByLabel('Wall 3 length', { exact: true }).blur(); await expect(page.getByText('3,000 mm to close')).toBeVisible(); await page.getByRole('button', { name: 'Add closing wall' }).click(); expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0); await openWalls(page); await expect(page.getByLabel('Wall 4 length', { exact: true })).toHaveValue('3000'); await page.getByLabel('Wall 4 length', { exact: true }).fill('3050'); + await page.getByLabel('Wall 4 length', { exact: true }).blur(); await expect(page.getByText(/3,000 mm expected; length differs by 50 mm/)).toBeVisible(); }); @@ -554,7 +560,6 @@ test('internal skirting corners retain coping blanks and expose the installed mi await expect(page.locator('.skirting-cuts .cut-summary')).toContainText( 'Buy 4 lengths · 1 installed uncut', ); - const piece = skirt.pieces.find((piece) => piece.id === 'A1')!; const entry = skirtEntries(page).filter({ has: page.getByRole('button', { name: 'Preview skirting cut A1', exact: true }), }); @@ -582,11 +587,11 @@ test('internal skirting corners retain coping blanks and expose the installed mi expect(Math.abs(geometry.preparationX)).toBeCloseTo(18, 3); expect(Math.abs(geometry.preparationY)).toBeCloseTo(18, 3); expect(geometry.installedX).toBeCloseTo(0, 3); - expect(piece.leftOverlap).toBeCloseTo(18, 3); await page.keyboard.press('Escape'); const floorCount = await floorEntries(page).count(); await page.getByText('Advanced skirting settings', { exact: true }).click(); await page.getByLabel('Minimum piece', { exact: true }).fill('2365'); + await page.getByLabel('Minimum piece', { exact: true }).blur(); await expect(skirtEntries(page)).toHaveCount(0); await expect(floorEntries(page)).toHaveCount(floorCount); await expect(page.locator('.issue.error').first()).toBeVisible(); @@ -602,7 +607,9 @@ test('door positions and material lengths update skirting immediately', async ({ 'W1: measure from W4 corner toward W2 corner.', ); await movedDoor.getByLabel(/^From W\d+ (corner|start)$/).fill('1200'); + await movedDoor.getByLabel(/^From W\d+ (corner|start)$/).blur(); await movedDoor.getByLabel('Door width', { exact: true }).fill('900'); + await movedDoor.getByLabel('Door width', { exact: true }).blur(); expect(state(page).project.rooms[0].walls[0].doors[0]).toMatchObject({ offset: 1200, width: 900, @@ -613,13 +620,16 @@ test('door positions and material lengths update skirting immediately', async ({ ); expect(await page.locator('.skirting-cuts').innerText()).not.toBe(before); await page.getByLabel('Skirting board length', { exact: true }).fill('3600'); + await page.getByLabel('Skirting board length', { exact: true }).blur(); await page.getByLabel('Skirting thickness', { exact: true }).fill('22'); + await page.getByLabel('Skirting thickness', { exact: true }).blur(); const { skirt } = plans(page); await expect(skirtCodes(page)).toHaveCount(skirtingCuts(skirt).length); await expect(page.locator('.skirting-cuts .cut-summary')).toContainText( `Buy ${skirt.stockCount} lengths`, ); await movedDoor.getByLabel('Door width', { exact: true }).fill('6000'); + await movedDoor.getByLabel('Door width', { exact: true }).blur(); await expect(page.getByText(/must fit within the wall/).first()).toBeVisible(); await expect(skirtEntries(page)).toHaveCount(0); }); @@ -627,10 +637,12 @@ test('door positions and material lengths update skirting immediately', async ({ test('invalid skirting settings keep flooring results usable', async ({ page }) => { const floorCount = await floorCodes(page).count(); await page.getByLabel('Skirting board length', { exact: true }).fill('100'); + await page.getByLabel('Skirting board length', { exact: true }).blur(); await expect(skirtEntries(page)).toHaveCount(0); await expect(floorCodes(page)).toHaveCount(floorCount); await expect(page.locator('.issue.error').first()).toBeVisible(); await page.getByLabel('Skirting board length', { exact: true }).fill('2400'); + await page.getByLabel('Skirting board length', { exact: true }).blur(); expect(await skirtEntries(page).count()).toBeGreaterThan(0); }); @@ -665,19 +677,13 @@ test('pattern, direction and spacers persist without steps, layers or stored res page, }) => { await page.getByLabel('Room length', { exact: true }).fill('3650'); + await page.getByLabel('Room length', { exact: true }).blur(); await page.getByRole('button', { name: 'Random', exact: true }).click(); await page.getByRole('button', { name: 'Across first wall', exact: true }).click(); await page.getByLabel('Wall spacer', { exact: true }).fill('12'); + await page.getByLabel('Wall spacer', { exact: true }).blur(); expect(state(page).project.flooring).toMatchObject({ pattern: 'random', gap: 12 }); expect(state(page).project.rooms[0].direction).toBe(90); - expect(Object.keys(state(page).view).sort()).toEqual([ - 'collapsedSections', - 'material', - 'roomId', - 'search', - 'selectedPiece', - ]); - expect(Object.keys(state(page)).sort()).toEqual(['project', 'view']); const table = await page.locator('.flooring-cuts').innerText(); await page.reload(); await expect(page.getByLabel('Wall spacer', { exact: true })).toHaveValue('12'); @@ -698,7 +704,7 @@ test('localStorage restores all project and view parameters when reopening witho context, }) => { const fixture = sharedRoomFixture(); - fixture.project.name = 'Café 北 flooring'; + fixture.project.rooms[0].name = 'Café 北 flooring'; fixture.project.tolerance = 7; fixture.project.flooring = { ...fixture.project.flooring, @@ -717,7 +723,8 @@ test('localStorage restores all project and view parameters when reopening witho fixture.project.completedBoards = ['floor:B1', 'skirting:S1']; fixture.view = { roomId: 'room-2', - selectedPiece: 'F1', + selectedPiece: 'flooring:F1', + focus: null, search: 'Study', collapsedSections: ['cuts', 'materials'], material: 'flooring', @@ -739,6 +746,7 @@ test('localStorage restores all project and view parameters when reopening witho await expect(reopened.locator('#materials-content')).toBeHidden(); await expect(reopened.locator('#cuts-content')).toBeHidden(); await reopened.getByLabel('Room name', { exact: true }).fill('Saved study'); + await reopened.getByLabel('Room name', { exact: true }).blur(); fixture.project.rooms[1].name = 'Saved study'; fixture.view.selectedPiece = null; expect(await savedQuery(reopened)).toBe(encodeState(fixture)); @@ -752,7 +760,9 @@ test('explicit query parameters override the saved plan and default omitted fiel page, }) => { await page.getByLabel('Room name', { exact: true }).fill('Saved room'); + await page.getByLabel('Room name', { exact: true }).blur(); await page.getByLabel('Wall spacer', { exact: true }).fill('12'); + await page.getByLabel('Wall spacer', { exact: true }).blur(); await page.goto('/?flooring.length=1400'); await expect(page.getByLabel('Board length', { exact: true })).toHaveValue('1400'); const expected = defaultState(); @@ -768,7 +778,7 @@ for (const saved of ['damaged data', '?flooring.length=broken']) { [PROJECT_STORAGE_KEY, saved], ); await page.goto('/?planner'); - await expect(page).toHaveURL(/\?version=2&/); + await expect(page).toHaveURL(/\?plan=/); await expect(page.locator('.url-error')).toHaveCount(0); expect(state(page)).toEqual(defaultState()); expect(await savedQuery(page)).toBe(encodeState(defaultState())); @@ -793,6 +803,7 @@ for (const failure of ['blocked', 'full']) { await page.goto('/?planner'); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room'); await page.getByLabel('Room name', { exact: true }).fill('URL still works'); + await page.getByLabel('Room name', { exact: true }).blur(); expect(state(page).project.rooms[0].name).toBe('URL still works'); await page.reload(); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('URL still works'); @@ -803,6 +814,7 @@ for (const failure of ['blocked', 'full']) { test('a narrow remainder is shared between the first and last flooring rows', async ({ page }) => { // 3650 minus two 10 mm spacers leaves 19 full rows and a 20 mm sliver. await page.getByLabel('Room length', { exact: true }).fill('3650'); + await page.getByLabel('Room length', { exact: true }).blur(); const { floor } = plans(page); for (const row of [1, 20]) { const piece = flooringCuts(floor).find((piece) => piece.row === row); @@ -818,7 +830,9 @@ test('multiple rooms retain independent measurements with combined cut lists', a await page.getByRole('button', { name: 'Add room', exact: true }).click(); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Room 2'); await page.getByLabel('Room name', { exact: true }).fill('Study'); + await page.getByLabel('Room name', { exact: true }).blur(); await page.getByLabel('Room width', { exact: true }).fill('3200'); + await page.getByLabel('Room width', { exact: true }).blur(); expect(state(page).project.rooms).toHaveLength(2); expect(state(page).project.rooms[0].walls[0].length).toBe(4800); expect(state(page).project.rooms[1].walls[0].length).toBe(3200); @@ -839,6 +853,7 @@ test('shared URLs reopen the selected preview and copying the link preserves the context, }) => { await page.getByLabel('Room name', { exact: true }).fill('Café 北'); + await page.getByLabel('Room name', { exact: true }).blur(); const { floor } = plans(page); const pieceCode = flooringCuts(floor)[0].id; await page @@ -847,7 +862,7 @@ test('shared URLs reopen the selected preview and copying the link preserves the await expect( page.getByRole('dialog', { name: `Flooring cut ${pieceCode}`, exact: true }), ).toBeVisible(); - expect(state(page).view.selectedPiece).toBe(pieceCode); + expect(state(page).view.selectedPiece).toBe(`flooring:${pieceCode}`); const shareUrl = page.url(); const originalTable = await page.locator('.flooring-cuts').innerText(); const shared = await context.newPage(); @@ -905,6 +920,7 @@ test('invalid URLs recover when measurements are edited', async ({ page }) => { await expect(page.getByText(/Could not load this plan/)).toBeVisible(); expect(await savedQuery(page)).toBe(saved); await page.getByLabel('Room name', { exact: true }).fill('Recovered plan'); + await page.getByLabel('Room name', { exact: true }).blur(); await expect(page.getByText(/Could not load this plan/)).toHaveCount(0); expect(decodeState(new URL(page.url()).search).error).toBeNull(); expect(new URL(page.url()).hash).toBe(''); @@ -928,31 +944,32 @@ test('clicking the skirting code itself opens its cutting dialog', async ({ page await expect( page.getByRole('dialog', { name: `Skirting cut ${id}`, exact: true }), ).toBeVisible(); - expect(state(page).view.selectedPiece).toBe(id); + expect(state(page).view.selectedPiece).toBe(`skirting:${id}`); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); } }); -test('readable query fields and browser history preserve the plan', async ({ page }) => { +test('the versioned payload and browser history preserve the plan', async ({ page }) => { const initial = defaultState(); initial.project.rooms[0].name = 'Current lounge'; initial.project.flooring.pattern = 'random'; await page.goto(`/${encodeState(initial)}`); - await expect(page).toHaveURL(/\?version=2&/); + await expect(page).toHaveURL(/\?plan=/); expect(state(page)).toEqual(initial); await expect.poll(() => savedQuery(page)).toBe(encodeState(initial)); const original = page.url(); await page.getByRole('button', { name: 'Add room', exact: true }).click(); await page.getByLabel('Room name', { exact: true }).fill('Study'); + await page.getByLabel('Room name', { exact: true }).blur(); await page.getByLabel('Room width', { exact: true }).fill('3333'); + await page.getByLabel('Room width', { exact: true }).blur(); const twoRooms = page.url(); - const params = new URL(twoRooms).searchParams; - expect(params.get('room.2.name')).toBe('Study'); - expect(params.get('room.2.wall.1.length')).toBe('3333'); - expect(params.get('flooring.pattern')).toBe('random'); - expect(params.has('plan')).toBe(false); - expect([...params.keys()].some((key) => /boardCount|polygon|pieceIds/.test(key))).toBe(false); + expect(state(page).project.rooms[1]).toMatchObject({ + name: 'Study', + walls: [{ length: 3333 }, {}, {}, {}], + }); + expect(state(page).project.flooring.pattern).toBe('random'); await page.goBack(); await expect(page).toHaveURL(original); await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Current lounge'); @@ -963,8 +980,9 @@ test('readable query fields and browser history preserve the plan', async ({ pag await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study'); await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333'); expect(await savedQuery(page)).toBe(new URL(twoRooms).search); - params.set('flooring.width', '205'); - await page.goto(`/?${params}`); + const edited = state(page); + edited.project.flooring.width = 205; + await page.goto(`/${encodeState(edited)}`); await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205'); await page.reload(); await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205'); @@ -992,6 +1010,7 @@ test('printing includes every room and every cut once, grouped by its source boa ).toBe(true); await expect(page.locator('.print-plan')).toBeHidden(); await page.emulateMedia({ media: 'print' }); + await expect(page.locator('.print-plan')).toBeVisible(); const printed = page.locator('.print-plan'); await expect(printed).toBeVisible(); await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeHidden(); @@ -1043,8 +1062,7 @@ test('panels collapse independently and the room stays pinned while reading cuts await panel(page, section).locator('.panel-toggle').click(); await expect(page.locator(`#${section}-content`)).toBeHidden(); } - expect(state(page).view.collapsedSections).toEqual(['cuts', 'materials', 'measurements']); - expect(new URL(page.url()).searchParams.getAll('collapsed')).toEqual([ + expect(state(page).view.collapsedSections.slice().sort()).toEqual([ 'cuts', 'materials', 'measurements', @@ -1083,7 +1101,7 @@ test('drawing cuts still open a usable preview when the cut plan is collapsed', const dialog = page.getByRole('dialog', { name: 'Flooring cut F1', exact: true }); await expect(dialog).toBeVisible(); await expect(dialog.locator('.cut-dimensions')).toBeVisible(); - expect(state(page).view.selectedPiece).toBe('F1'); + expect(state(page).view.selectedPiece).toBe('flooring:F1'); await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0); await expect(page.locator('#cuts-content')).toBeHidden(); @@ -1100,7 +1118,7 @@ test('negative angles can be backspaced and retyped without saving unfinished dr await turn.press('End'); await turn.press('Backspace'); await expect(turn).toHaveValue('-9'); - expect(state(page).project.rooms[0].walls[3].turn).toBe(-9); + expect(state(page).project.rooms[0].walls[3].turn).toBe(-90); const committed = page.url(); await turn.press('Backspace'); await expect(turn).toHaveValue('-'); @@ -1108,7 +1126,15 @@ test('negative angles can be backspaced and retyped without saving unfinished dr await turn.press('Backspace'); await expect(turn).toHaveValue(''); expect(page.url()).toBe(committed); - await turn.pressSequentially('-90'); + await turn.pressSequentially('-45'); + expect(page.url()).toBe(committed); + await turn.press('Escape'); + await expect(turn).toHaveValue('-90'); + await turn.fill('-80'); + await turn.press('Enter'); + expect(state(page).project.rooms[0].walls[3].turn).toBe(-80); + await turn.fill('-90'); + await turn.blur(); await expect(turn).toHaveValue('-90'); expect(state(page).project.rooms[0].walls[3].turn).toBe(-90); expect(await preview(page).locator('.floor-piece').count()).toBeGreaterThan(0); @@ -1390,14 +1416,17 @@ test('completed source boards and locate actions follow shared offcuts across ro await done.check(); await expect(group).toHaveClass(/is-done/); expect(state(page).project.completedBoards).toEqual([key]); - expect(new URL(page.url()).searchParams.getAll('done')).toEqual([key]); await group.getByRole('button', { name: `Show cuts ${groupCodes} on plan`, exact: true }).click(); - expect(state(page).view.search).toBe(key); + expect(state(page).view.focus).toEqual({ kind: 'board', material: 'flooring', id: shared.id }); + expect(state(page).view.search).toBe(''); const search = page.getByLabel('Find a cut or room', { exact: true }); - await expect(search).toHaveValue(groupCodes); + await expect(search).toHaveValue(''); + await expect(page.locator('.plan-search-status')).toContainText(groupCodes); await noVisibleStockIds(page); await search.fill(''); + await search.blur(); await search.fill(groupCodes); + await search.blur(); expect(state(page).view.search).toBe(groupCodes); await noVisibleStockIds(page); expect(await highlightedIds(page, 'flooring')).toEqual( @@ -1425,19 +1454,21 @@ test('completed source boards and locate actions follow shared offcuts across ro const piece = sourcePieces.find((piece) => piece.roomId === 'room-2')!; await page.getByRole('button', { name: `Show cut ${piece.id} on plan`, exact: true }).click(); expect(state(page).view.roomId).toBe('room-2'); - expect(state(page).view.search).toBe(piece.id); + expect(state(page).view.focus).toEqual({ kind: 'piece', material: 'flooring', id: piece.id }); expect(await highlightedIds(page)).toEqual([piece.id]); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(group.locator('.board-cut')).toHaveCount(sourcePieces.length); await page.reload(); - await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(piece.id); + await expect(page.getByLabel('Find a cut or room', { exact: true })).toHaveValue(''); + await expect(page.locator('.plan-search-status')).toContainText(piece.id); await expect(done).toBeChecked(); expect(await highlightedIds(page)).toEqual([piece.id]); await page.getByLabel('Room name', { exact: true }).fill('Office'); + await page.getByLabel('Room name', { exact: true }).blur(); expect(state(page).project.completedBoards).toEqual([key]); await page.locator('.room-tab').first().click(); expect(state(page).project.completedBoards).toEqual([key]); - await page.getByLabel('Find a cut or room', { exact: true }).fill(''); + await page.getByRole('button', { name: 'Show all pieces', exact: true }).click(); const skirtPiece = skirtingCuts(skirt).find((piece) => piece.roomId === 'room-1')!; const skirtKey = `skirting:${skirtPiece.stockId}`; await page @@ -1451,9 +1482,9 @@ test('completed source boards and locate actions follow shared offcuts across ro .click(); expect(await preview(page).locator('.skirt-piece[data-done="true"]').count()).toBeGreaterThan(0); await page.getByLabel('Board width', { exact: true }).fill('201'); + await page.getByLabel('Board width', { exact: true }).blur(); expect(state(page).project.completedBoards).toEqual([]); await expect(preview(page).locator('[data-done="true"]')).toHaveCount(0); - expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]); }); test('the pinned plan makes active search obvious and can restore every piece', async ({ @@ -1461,6 +1492,7 @@ test('the pinned plan makes active search obvious and can restore every piece', }) => { const allCuts = await floorEntries(page).count(); await page.getByLabel('Find a cut or room', { exact: true }).fill('F1'); + await page.getByLabel('Find a cut or room', { exact: true }).blur(); const banner = page.locator('.drawing-column .plan-search-status'); await expect(banner).toContainText('Search active'); await expect(banner).toContainText('F1'); @@ -1508,18 +1540,17 @@ test('cuts from one board share the available width and wrap on a narrow phone', await noVisibleStockIds(page); }); -test('search can locate and complete a whole board without inventing a cut code', async ({ - page, -}) => { +test('locate and complete a whole board without inventing a cut code', async ({ page }) => { const { floor } = plans(page); const whole = floor.pieces.find((piece) => !piece.cut && !piece.reused)!; expect(whole).toBeDefined(); const search = page.getByLabel('Find a cut or room', { exact: true }); - await search.fill(`floor:${whole.stockId}`); + await search.fill('Living room'); const group = page.locator(`.stock-cut-group[data-board-key="floor:${whole.stockId}"]`); await expect(group).toContainText('Installed uncut'); await group.getByRole('button', { name: 'Show uncut floorboard on plan', exact: true }).click(); - await expect(search).toHaveValue('Uncut floorboard'); + await expect(search).toHaveValue(''); + await expect(page.locator('.plan-search-status')).toContainText('Uncut floorboard'); await noVisibleStockIds(page); await expect(group.locator('.board-cut')).toHaveCount(0); await expect(group.getByRole('button', { name: /^Preview/ })).toHaveCount(0); @@ -1533,6 +1564,7 @@ test('search can locate and complete a whole board without inventing a cut code' 'true', ); await search.fill('nothing matches this room'); + await search.blur(); await expect(page.locator('.search-results')).toHaveText('No matching boards or cuts.'); expect(await highlightedIds(page)).toEqual([]); expect(state(page).project.completedBoards).toEqual([`floor:${whole.stockId}`]); @@ -1609,3 +1641,45 @@ for (const width of [320, 390]) { await noHorizontalOverflow(page); }); } + +test('material-qualified selections disambiguate flooring and sixth-room skirting F1', async ({ + page, +}) => { + const fixture = defaultState(); + fixture.project.rooms = Array.from({ length: 6 }, (_, i) => + rectangle(`room-${i + 1}`, `Room ${i + 1}`, 2400, 2400), + ); + fixture.view.roomId = 'room-6'; + fixture.view.material = 'skirting'; + await page.goto(`/${encodeState(fixture)}`); + await preview(page).getByRole('button', { name: 'Select skirting cut F1', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toHaveAccessibleName('Skirting cut F1'); + await expect(dialog).toContainText('Room 6'); + await expect(dialog.locator('.flooring-preview')).toHaveCount(0); + expect(state(page).view.selectedPiece).toBe('skirting:F1'); + await page.reload(); + await expect(dialog).toHaveAccessibleName('Skirting cut F1'); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Preview flooring cut F1', exact: true }).click(); + await expect(dialog).toHaveAccessibleName('Flooring cut F1'); + await expect(dialog).toContainText('Room 1'); + await expect(dialog.locator('.skirting-full-preview')).toHaveCount(0); + expect(state(page).view.selectedPiece).toBe('flooring:F1'); +}); + +test('native print events build current sheets synchronously and release them afterward', async ({ + page, +}) => { + await expect(page.locator('.print-plan')).toHaveCount(0); + await page.getByLabel('Room name', { exact: true }).fill('Print this room'); + const printed = await page.evaluate(() => { + window.dispatchEvent(new Event('beforeprint')); + return document.querySelector('.print-plan')?.textContent; + }); + expect(printed).toContain('Print this room'); + expect(printed).toContain('Flooring cuts'); + expect(printed).toContain('Skirting cuts'); + await page.evaluate(() => window.dispatchEvent(new Event('afterprint'))); + await expect(page.locator('.print-plan')).toHaveCount(0); +}); diff --git a/e2e/usability.spec.ts b/e2e/usability.spec.ts index 880a4e5..aa93ed2 100644 --- a/e2e/usability.spec.ts +++ b/e2e/usability.spec.ts @@ -23,8 +23,10 @@ test('new plans start random and guide custom measurements through to the cut pl await expect(page.getByLabel('Wall 1 length', { exact: true })).toBeVisible(); await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); await page.getByLabel('Wall 2 length', { exact: true }).fill('3000'); + await page.getByLabel('Wall 2 length', { exact: true }).blur(); await page.getByRole('button', { name: 'Add a wall', exact: true }).click(); await page.getByLabel('Wall 3 length', { exact: true }).fill('4000'); + await page.getByLabel('Wall 3 length', { exact: true }).blur(); await page.getByRole('button', { name: 'Add closing wall', exact: true }).click(); await expect(page.getByText('Outline closed', { exact: true })).toBeVisible(); await expect(page.getByLabel('Wall 4 length', { exact: true })).toBeVisible(); @@ -41,6 +43,7 @@ test('new plans start random and guide custom measurements through to the cut pl await expect(page.getByLabel('Saw kerf', { exact: true })).toBeHidden(); await page.getByText('Advanced flooring settings', { exact: true }).click(); await page.getByLabel('Saw kerf', { exact: true }).fill('4'); + await page.getByLabel('Saw kerf', { exact: true }).blur(); expect(decodeState(new URL(page.url()).search).state.project.flooring.kerf).toBe(4); await page.getByRole('button', { name: 'Cut plan', exact: true }).click(); @@ -120,6 +123,7 @@ test('locating cuts changes material automatically and printing includes both ma expect(await preview.locator('.skirt-piece[data-highlighted="true"]').count()).toBeGreaterThan(0); await page.getByRole('button', { name: 'Show all pieces', exact: true }).click(); await page.getByLabel('Find a cut or room', { exact: true }).fill('F1'); + await page.getByLabel('Find a cut or room', { exact: true }).blur(); await expect(toggle.getByRole('button', { name: 'Floorboards', exact: true })).toHaveAttribute( 'aria-pressed', 'true', @@ -137,6 +141,7 @@ test('locating cuts changes material automatically and printing includes both ma await expect(page.getByRole('dialog')).toBeVisible(); await page.keyboard.press('Escape'); await page.emulateMedia({ media: 'print' }); + await expect(page.locator('.print-plan')).toBeVisible(); expect(await page.locator('.print-plan .floor-piece').count()).toBeGreaterThan(0); expect(await page.locator('.print-plan .skirt-piece').count()).toBeGreaterThan(0); }); diff --git a/src/App.tsx b/src/App.tsx index 8aa2cde..02d9b23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,16 @@ -import { useMemo, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { Copy, Plus, Printer, Search, X } from 'lucide-react'; +import { usePrinting } from './lib/use-printing'; import { useProject } from './lib/use-project'; import { trackCopyLink, trackPrint, trackRoomAdded, trackRoomRemoved } from './lib/analytics'; -import { rectangle, nextId } from './lib/defaults'; -import { buildGeometry } from './lib/geometry'; -import { planFlooringOptions } from './lib/flooring'; -import { planSkirting } from './lib/skirting'; +import { createPlanCalculator } from './lib/planning'; +import { encodeState } from './lib/url-state'; import { area, mm } from './lib/format'; -import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list'; -import type { PlanMaterial, PlannerSection, Project, Room } from './lib/types'; -import { boardSearchLabel, findBoardMatches } from './lib/board-tracking'; -import { PRECISION, PROJECT_LIMITS } from './lib/limits'; +import { cutData } from './lib/cut-list'; +import type { PlanMaterial, PlanFocus, PlannerSection, Room } from './lib/types'; +import { focusLabel, findBoardMatches, pieceKey } from './lib/board-tracking'; +import { PROJECT_LIMITS } from './lib/limits'; import { OutlineEditor } from './components/OutlineEditor'; import { MaterialsEditor } from './components/MaterialsEditor'; import { FloorPlan } from './components/FloorPlan'; @@ -64,173 +63,80 @@ function PlannerPanel({ ); } -// Completion refers to this exact allocation; names and view preferences do not change it. -const allocationInputs = (project: Project) => - JSON.stringify({ - rooms: project.rooms.map(({ id, walls, direction }) => ({ id, walls, direction })), - flooring: project.flooring, - skirting: project.skirting, - tolerance: project.tolerance, - }); - export default function App() { - const { state, error, update } = useProject(); + const { state, error, dispatch } = useProject(); const { project, view } = state; + const printing = usePrinting(); const copyStatus = useRef(null); - const room = project.rooms.find((r) => r.id === view.roomId) ?? project.rooms[0]; - const geometries = useMemo( - () => project.rooms.map((r) => buildGeometry(r, project.tolerance)), - [project.rooms, project.tolerance], + const [calculate] = useState(createPlanCalculator); + const { geometries, flooringOptions, skirting } = useMemo( + () => calculate(project), + [calculate, project], ); + const room = project.rooms.find((room) => room.id === view.roomId) ?? project.rooms[0]; const geometry = geometries[project.rooms.indexOf(room)]; - // Switching between already calculated options only selects a result. - const flooringInputs = JSON.stringify({ ...project.flooring, optimisation: undefined }); - const flooringOptions = useMemo( - () => planFlooringOptions(project.rooms, project.flooring, project.tolerance), - [project.rooms, flooringInputs, project.tolerance], - ); const flooring = flooringOptions[project.flooring.optimisation]; - const skirting = useMemo( - () => planSkirting(project.rooms, project.skirting, project.tolerance), - [project.rooms, project.skirting, project.tolerance], + const cuts = useMemo( + () => cutData(flooring, skirting, project.flooring), + [flooring, skirting, project.flooring], ); - const floorErrors = flooring.issues.filter((i) => i.severity === 'error'); - const skirtErrors = skirting.issues.filter((i) => i.severity === 'error'); + const roomNames = useMemo( + () => new Map(project.rooms.map((room) => [room.id, room.name])), + [project.rooms], + ); + const floorErrors = flooring.issues.filter((issue) => issue.severity === 'error'); + const skirtErrors = skirting.issues.filter((issue) => issue.severity === 'error'); const roomErrors = [...floorErrors, ...skirtErrors].filter( (issue) => !issue.roomId || issue.roomId === room.id, ); - const totalArea = geometries.reduce((sum, g) => sum + (g.valid ? g.area : 0), 0); - const wholeBoards = flooring.pieces.filter( - (p) => - !p.cut && - Math.abs(p.length - project.flooring.length) < PRECISION.millimetres && - Math.abs(p.width - project.flooring.width) < PRECISION.millimetres, - ).length; - const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length; - - const changeProject = (fn: (p: Project) => Project) => - update((s) => { - const project = fn(s.project); - const changed = allocationInputs(project) !== allocationInputs(s.project); - return { - ...s, - project: changed ? { ...project, completedBoards: [] } : project, - view: { ...s.view, selectedPiece: null, search: changed ? '' : s.view.search }, - }; - }); - const matches = useMemo( - () => findBoardMatches(project, flooring, skirting, view.search), - [project, flooring, skirting, view.search], + const totalArea = geometries.reduce( + (sum, geometry) => sum + (geometry.valid ? geometry.area : 0), + 0, ); - const searchLabel = boardSearchLabel(project, flooring, skirting, view.search); + const matches = useMemo( + () => findBoardMatches(project, flooring, skirting, view.search, view.focus), + [project.rooms, flooring, skirting, view.search, view.focus], + ); + const searchActive = !!view.search.trim() || view.focus !== null; + const searchLabel = view.focus ? focusLabel(view.focus, cuts) : view.search; const visibleMatchCount = (view.material === 'flooring' ? flooring : skirting).pieces.filter( - (piece) => matches.pieceIds.includes(piece.id), + (piece) => matches.pieceKeys.has(pieceKey(view.material, piece.id)), ).length; - const materialForPieces = (ids: string[], current: PlanMaterial): PlanMaterial => { - const hasFloor = flooring.pieces.some((piece) => ids.includes(piece.id)); - const hasSkirting = skirting.pieces.some((piece) => ids.includes(piece.id)); - return hasFloor && !hasSkirting ? 'flooring' : hasSkirting && !hasFloor ? 'skirting' : current; - }; - const setSearch = (search: string) => - update((s) => { - const found = findBoardMatches(s.project, flooring, skirting, search); - const roomId = found.roomIds.includes(s.view.roomId) - ? s.view.roomId - : (found.roomIds[0] ?? s.view.roomId); - return { - ...s, - view: { - ...s.view, - search, - roomId, - selectedPiece: null, - material: materialForPieces(found.pieceIds, s.view.material), - }, - }; + const setSearch = (text: string) => { + const found = findBoardMatches(project, flooring, skirting, text); + dispatch({ + type: 'search', + text, + roomId: found.roomIds.includes(view.roomId) ? view.roomId : (found.roomIds[0] ?? view.roomId), + material: found.materials.size === 1 ? [...found.materials][0] : view.material, }); - const togglePanel = (section: PlannerSection) => - update((s) => ({ - ...s, - view: { - ...s.view, - collapsedSections: s.view.collapsedSections.includes(section) - ? s.view.collapsedSections.filter((item) => item !== section) - : [...s.view.collapsedSections, section], - }, - })); - const toggleBoard = (key: string) => - update((s) => ({ - ...s, - project: { - ...s.project, - completedBoards: s.project.completedBoards.includes(key) - ? s.project.completedBoards.filter((item) => item !== key) - : [...s.project.completedBoards, key], - }, - })); - const locatePiece = (id: string, roomId: string) => - update((s) => ({ - ...s, - view: { - ...s.view, - search: id, - roomId, - selectedPiece: null, - material: materialForPieces([id], s.view.material), - }, - })); - const changeRoom = (fn: (r: Room) => Room) => - changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) })); - const select = (id: string | null, roomId?: string) => - update((s) => ({ - ...s, - view: { - ...s.view, - roomId: roomId ?? s.view.roomId, - selectedPiece: id, - material: id ? materialForPieces([id], s.view.material) : s.view.material, - }, - })); + }; + const locate = (focus: PlanFocus) => { + const found = findBoardMatches(project, flooring, skirting, '', focus); + dispatch({ + type: 'focus', + focus, + roomId: found.roomIds.includes(view.roomId) ? view.roomId : (found.roomIds[0] ?? view.roomId), + }); + }; + const select = (material: PlanMaterial, id: string, roomId?: string) => + dispatch({ type: 'select', material, id, roomId }); + const togglePanel = (section: PlannerSection) => dispatch({ type: 'toggleSection', section }); + const changeRoom = (change: (room: Room) => Room) => + dispatch({ type: 'room', id: room.id, change }); const addRoom = () => { - update((s) => { - const id = nextId( - 'room', - s.project.rooms.map((r) => r.id), - ); - return { - ...s, - project: { - ...s.project, - completedBoards: [], - rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)], - }, - view: { - ...s.view, - roomId: id, - selectedPiece: null, - search: '', - collapsedSections: s.view.collapsedSections.filter( - (section) => section !== 'measurements', - ), - }, - }; - }, true); + dispatch({ type: 'addRoom' }); trackRoomAdded(); }; const removeRoom = () => { - update((s) => { - const rooms = s.project.rooms.filter((r) => r.id !== room.id); - return { - ...s, - project: { ...s.project, rooms, completedBoards: [] }, - view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' }, - }; - }, true); + dispatch({ type: 'removeRoom', id: room.id }); trackRoomRemoved(); }; const copyLink = async () => { try { - await navigator.clipboard.writeText(window.location.href); + await navigator.clipboard.writeText( + `${window.location.origin}${window.location.pathname}${encodeState(state)}`, + ); if (copyStatus.current) copyStatus.current.textContent = 'Link copied'; trackCopyLink(); } catch { @@ -282,12 +188,7 @@ export default function App() { key={r.id} className={`room-tab ${r.id === room.id ? 'active' : ''}`} aria-pressed={r.id === room.id} - onClick={() => - update( - (s) => ({ ...s, view: { ...s.view, roomId: r.id, selectedPiece: null } }), - true, - ) - } + onClick={() => dispatch({ type: 'visitRoom', id: r.id })} > {r.name || 'Untitled room'} @@ -334,7 +235,7 @@ export default function App() { geometry={geometry} tolerance={project.tolerance} changeRoom={changeRoom} - changeProject={changeProject} + setTolerance={(value) => dispatch({ type: 'tolerance', value })} remove={removeRoom} canRemove={project.rooms.length > 1} /> @@ -359,7 +260,8 @@ export default function App() { flooringOptions={flooringOptions} room={room} changeRoom={changeRoom} - changeProject={changeProject} + setFlooring={(patch) => dispatch({ type: 'flooring', patch })} + setSkirting={(patch) => dispatch({ type: 'skirting', patch })} />
@@ -375,7 +277,7 @@ export default function App() {

{mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '} - {wholeBoards} used whole + {cuts.wholeFloor.wholeBoards} used whole

@@ -390,7 +292,7 @@ export default function App() {

{skirting.stockCount} × {mm(project.skirting.stockLength)} mm ·{' '} - {wholeSkirting} used whole + {cuts.wholeSkirt} used whole

@@ -414,11 +316,14 @@ export default function App() { skirting={skirting} select={select} completedBoards={project.completedBoards} - toggleBoard={toggleBoard} + toggleBoard={(key) => dispatch({ type: 'toggleBoard', key })} + cuts={cuts} + matches={matches} + searchActive={searchActive} + roomNames={roomNames} search={view.search} setSearch={setSearch} - locateBoard={setSearch} - locatePiece={locatePiece} + locate={locate} /> @@ -436,12 +341,7 @@ export default function App() { key={material} className={view.material === material ? 'active' : ''} aria-pressed={view.material === material} - onClick={() => - update((s) => ({ - ...s, - view: { ...s.view, material, selectedPiece: null, search: '' }, - })) - } + onClick={() => dispatch({ type: 'material', material })} > - {view.search.trim() && ( + {searchActive && (

{searchLabel} ·{' '} @@ -480,12 +380,7 @@ export default function App() { @@ -500,27 +395,30 @@ export default function App() { flooring={flooring} skirting={skirting} selectedPiece={view.selectedPiece} - select={(id) => select(id)} + select={select} completedBoards={project.completedBoards} - highlightedPieces={matches.pieceIds} - searchActive={!!view.search.trim()} + highlightedPieces={matches.pieceKeys} + searchActive={searchActive} material={view.material} />

piece.id === view.selectedPiece)} - skirt={skirtingCuts(skirting).find((piece) => piece.id === view.selectedPiece)} + selection={view.selectedPiece ? cuts.selections.get(view.selectedPiece) : undefined} project={project} - close={() => select(null)} - /> - dispatch({ type: 'closePreview' })} /> + {printing && ( + + )} ); } diff --git a/src/components/CutDialog.tsx b/src/components/CutDialog.tsx index e7f7f67..fd62d92 100644 --- a/src/components/CutDialog.tsx +++ b/src/components/CutDialog.tsx @@ -1,18 +1,20 @@ +import type { CutSelection } from '../lib/cut-list'; import { useEffect, useRef } from 'react'; import { X } from 'lucide-react'; -import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types'; +import type { Project } from '../lib/types'; import { flooringInstructions } from '../lib/cut-instructions'; import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list'; import { FlooringPreview, SkirtingPreview } from './CutPreview'; interface Props { - floor?: FlooringPiece; - skirt?: SkirtingPiece; + selection?: CutSelection; project: Project; close: () => void; } -export function CutDialog({ floor, skirt, project, close }: Props) { +export function CutDialog({ selection, project, close }: Props) { + const floor = selection?.material === 'flooring' ? selection.piece : undefined; + const skirt = selection?.material === 'skirting' ? selection.piece : undefined; const dialog = useRef(null); const opener = useRef(null); const selected = floor ?? skirt; @@ -27,7 +29,7 @@ export function CutDialog({ floor, skirt, project, close }: Props) { element.close(); if (opener.current?.isConnected) opener.current.focus({ preventScroll: true }); } - }, [selected?.id]); + }, [selection?.material, selected?.id]); const room = selected && diff --git a/src/components/CutList.tsx b/src/components/CutList.tsx index 2482816..2a902af 100644 --- a/src/components/CutList.tsx +++ b/src/components/CutList.tsx @@ -1,16 +1,10 @@ import { MapPin, X } from 'lucide-react'; -import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types'; +import type { FlooringPlan, Project, SkirtingPlan, PlanMaterial, PlanFocus } from '../lib/types'; import { cutMm, mm } from '../lib/format'; -import { - flooringCuts, - groupFlooringCuts, - groupSkirtingCuts, - skirtingCuts, - skirtingEndLabel, - uncutFlooring, - uncutSkirting, -} from '../lib/cut-list'; -import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking'; +import { skirtingEndLabel } from '../lib/cut-list'; +import type { CutData } from '../lib/cut-list'; +import { pieceKey } from '../lib/board-tracking'; +import type { BoardMatches } from '../lib/board-tracking'; import { PERSISTENCE_LIMITS } from '../lib/limits'; import { FlooringPreview, SkirtingPreview } from './CutPreview'; @@ -60,13 +54,16 @@ interface Props { project: Project; flooring: FlooringPlan; skirting: SkirtingPlan; - select: (id: string | null, roomId?: string) => void; + select: (material: PlanMaterial, id: string, roomId?: string) => void; completedBoards: string[]; toggleBoard: (key: string) => void; search: string; setSearch: (query: string) => void; - locateBoard: (key: string) => void; - locatePiece: (id: string, roomId: string) => void; + locate: (focus: PlanFocus) => void; + cuts: CutData; + matches: BoardMatches; + searchActive: boolean; + roomNames: Map; } export function CutList({ @@ -78,23 +75,15 @@ export function CutList({ toggleBoard, search, setSearch, - locateBoard, - locatePiece, + locate, + cuts, + matches, + searchActive: searching, + roomNames, }: Props) { - const floorCuts = flooringCuts(flooring), - skirtCuts = skirtingCuts(skirting); - const wholeFloor = uncutFlooring(flooring, project.flooring), - wholeSkirt = uncutSkirting(skirting); - const floorGroups = new Map( - groupFlooringCuts(flooring).map((group) => [group[0].stockId, group]), - ); - const skirtGroups = new Map( - groupSkirtingCuts(skirting).map((group) => [group[0].stockId, group]), - ); - const searching = search.trim().length > 0; - const matches = findBoardMatches(project, flooring, skirting, search); - const matchedBoards = new Set(matches.boardKeys), - matchedPieces = new Set(matches.pieceIds); + const { floorCuts, skirtCuts, floorGroups, skirtGroups, wholeFloor, wholeSkirt } = cuts; + const matchedBoards = matches.boardKeys, + matchedPieces = matches.pieceKeys; const completed = new Set(completedBoards); // Keep every cut from a matching source together, even when only one piece or room matched. const floorStocks = flooring.stocks.filter((stock) => @@ -103,7 +92,7 @@ export function CutList({ const skirtStocks = skirting.stocks.filter((stock) => searching ? matchedBoards.has(`skirting:${stock.id}`) : skirtGroups.has(stock.id), ); - const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id; + const roomName = (id: string) => roomNames.get(id) ?? id; return (
@@ -120,7 +109,7 @@ export function CutList({ setSearch(event.target.value)} placeholder="F12, A2, or a room name" @@ -139,8 +128,8 @@ export function CutList({
{searching && (

- {matches.boardKeys.length - ? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}` + {matches.boardKeys.size + ? `${matches.boardKeys.size} matching board${matches.boardKeys.size === 1 ? '' : 's'}` : 'No matching boards or cuts.'}

)} @@ -175,13 +164,13 @@ export function CutList({ size={`${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`} done={done} toggle={() => toggleBoard(key)} - locate={() => locateBoard(key)} + locate={() => locate({ kind: 'board', material: 'flooring', id: stock.id })} /> {group.length ? (
    {group.map((piece) => (
  • @@ -189,7 +178,7 @@ export function CutList({ className="cut-preview-button flooring-preview-button" aria-label={`Preview flooring cut ${piece.id}`} aria-haspopup="dialog" - onClick={() => select(piece.id, piece.roomId)} + onClick={() => select('flooring', piece.id, piece.roomId)} >