landing
All checks were successful
Deploy to Pages / build (push) Successful in 26s

This commit is contained in:
Andras Schmelczer 2026-09-11 20:34:36 +01:00
commit b9485e4097
32 changed files with 4692 additions and 65 deletions

52
src/lib/analytics.ts Normal file
View file

@ -0,0 +1,52 @@
import {
init as plausibleInit,
track as plausibleTrack,
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'floor-planner.schmelczer.dev';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
const ANALYTICS_LOGGING = import.meta.env.DEV;
let isInitialized = false;
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
try {
plausibleTrack(eventName, options);
} catch (error) {
console.warn(`Could not track analytics event "${eventName}".`, error);
}
};
export const initAnalytics = () => {
if (isInitialized) {
return;
}
try {
plausibleInit({
domain: ANALYTICS_DOMAIN,
endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: ANALYTICS_LOGGING,
// Query strings and legacy fragments contain the saved plan, including room names.
transformRequest: (payload) => ({
...payload,
u: payload.u.split(/[?#]/)[0],
r: payload.r?.split(/[?#]/)[0] ?? payload.r,
}),
});
isInitialized = true;
} catch (error) {
console.warn('Could not initialize analytics.', error);
}
};
export const trackRoomAdded = () => track('Add Room');
export const trackRoomRemoved = () => track('Remove Room');
export const trackCopyLink = () => track('Copy Link');
export const trackPrint = () => track('Print');

View file

@ -43,6 +43,7 @@ export function defaultProject(): Project {
width: 190,
gap: 10,
pattern: 'brick',
optimisation: 'cuts',
minOffset: 300,
maxOffset: 600,
minLength: 200,

View file

@ -16,6 +16,39 @@ const SCALE = 1000;
const EPSILON = 0.01;
const MAX_BOARDS = 2000;
export type FlooringOptions = Record<'cuts' | 'random', FlooringPlan>;
/** Compare both searches before presenting their actual cuts and waste to the user. */
export function planFlooringOptions(
rooms: Room[],
s: FlooringSettings,
tolerance: number,
): FlooringOptions {
const shapes: ShapeCache = new Map();
const cutsSettings = { ...s, optimisation: 'cuts' as const };
const randomSettings = { ...s, optimisation: 'random' as const };
const cuts = solveFlooring(rooms, cutsSettings, tolerance, shapes);
const random =
s.pattern === 'random' ? solveFlooring(rooms, randomSettings, tolerance, shapes) : cuts;
const errors = (solution: Solution) =>
solution.issues.filter((issue) => issue.severity === 'error').length;
const better = (settings: FlooringSettings) =>
(errors(random) - errors(cuts) || compareLayouts(random.layout, cuts.layout, settings)) < 0
? random
: cuts;
const cutWinner = better(cutsSettings);
const randomWinner = better(randomSettings);
const cutPlan = finishFlooring(cutWinner, s);
return {
cuts: cutPlan,
random: randomWinner === cutWinner ? cutPlan : finishFlooring(randomWinner, s),
};
}
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
return planFlooringOptions(rooms, s, tolerance)[s.optimisation ?? 'cuts'];
}
interface Bounds {
minX: number;
maxX: number;
@ -31,6 +64,8 @@ interface Layout {
rows: FlooringRow[];
stocks: AvailableStock[];
cuts: number;
shapingCuts: number;
ripCuts: number;
reusedCount: number;
patternPenalty: number;
}
@ -48,6 +83,12 @@ interface RowShape {
bounds: Bounds;
}
interface Solution {
layout: Layout;
issues: Issue[];
}
type ShapeCache = Map<string, { shape: RowShape; cuts: Map<number, CutPiece[] | null> }>;
const round = (n: number) => Math.round(n * SCALE) / SCALE;
const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length;
const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
@ -228,7 +269,7 @@ function allocate(
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
const length = round(piece.bounds.maxX - piece.bounds.minX);
let stock: AvailableStock | undefined;
for (const candidate of layout.stocks) {
for (const candidate of piece.needsLeft && piece.needsRight ? [] : layout.stocks) {
if (
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
(piece.needsLeft && !candidate.left) ||
@ -249,6 +290,18 @@ function allocate(
};
layout.stocks.push(stock);
}
const allocation = takeStock(stock, length, piece.needsRight, pieceId, s);
if (allocation.reused) layout.reusedCount++;
return allocation;
}
function takeStock(
stock: AvailableStock,
length: number,
needsRight: boolean,
pieceId: string,
s: FlooringSettings,
) {
const reused = stock.pieceIds.length > 0;
const reserved = blankLength(length, stock.remaining);
const crosscut = stock.remaining - length > EPSILON;
@ -260,13 +313,88 @@ function allocate(
} else {
stock.remaining = round(Math.max(0, stock.remaining - reserved - s.kerf));
// Take starters from the right; take finishers from the left. The remainder has one new cut end.
if (piece.needsRight) stock.right = false;
if (needsRight) stock.right = false;
else stock.left = false;
}
if (reused) layout.reusedCount++;
return { stockId: stock.id, reused, crosscut, blankLength: reserved };
}
/** Pair boundary blanks after seeing their future demand, preserving factory ends and cut order. */
function repack(layout: Layout, s: FlooringSettings): Layout {
const byId = new Map(layout.pieces.map((piece, index) => [piece.id, { piece, index }]));
// Neutral pieces can occupy a board's middle. Keep these more general cutting patterns intact.
const fixed = layout.stocks.filter((stock) =>
stock.pieceIds.some((id) => {
const p = byId.get(id)!.piece;
return !p.factoryLeft && !p.factoryRight;
}),
);
const fixedIds = new Set(fixed.flatMap((stock) => stock.pieceIds));
const movable = layout.pieces.filter((p) => !fixedIds.has(p.id));
const left = movable.filter((p) => p.factoryLeft && !p.factoryRight);
const right = movable.filter((p) => p.factoryRight && !p.factoryLeft);
const paired = new Set<string>();
const groups: string[][] = fixed.map((stock) => [...stock.pieceIds]);
const pairCuts = (a: FlooringPiece, b: FlooringPiece) => {
if (byId.get(a.id)!.index > byId.get(b.id)!.index) [a, b] = [b, a];
const remaining = round(Math.max(0, s.length - blankLength(a.length, s.length) - s.kerf));
if (blankLength(b.length, remaining) > remaining + EPSILON) return Infinity;
return Number(!close(a.length, s.length)) + Number(!close(b.length, remaining));
};
const pair = (a: FlooringPiece, b: FlooringPiece) => {
paired.add(a.id);
paired.add(b.id);
groups.push([a.id, b.id]);
};
// An exact complementary tail eliminates a saw pass. Reserve those matches first.
for (const a of left) {
const b = right.find((b) => !paired.has(b.id) && pairCuts(a, b) === 1);
if (b) pair(a, b);
}
// With saw passes fixed, fit large finishers with small starters to reduce purchased stock.
right.sort((a, b) => a.length - b.length);
for (const a of left.sort((a, b) => b.length - a.length)) {
if (paired.has(a.id)) continue;
const b = right.find((b) => !paired.has(b.id) && Number.isFinite(pairCuts(a, b)));
if (b) pair(a, b);
}
for (const p of movable) if (!paired.has(p.id)) groups.push([p.id]);
const index = (id: string) => byId.get(id)!.index;
groups.forEach((ids) => ids.sort((a, b) => index(a) - index(b)));
groups.sort((a, b) => index(a[0]) - index(b[0]));
const result: Layout = {
...layout,
pieces: [...layout.pieces],
stocks: [],
cuts: layout.shapingCuts + layout.ripCuts,
reusedCount: 0,
};
for (const ids of groups) {
const stock: AvailableStock = {
id: `B${result.stocks.length + 1}`,
pieceIds: [],
remaining: s.length,
left: true,
right: true,
};
result.stocks.push(stock);
for (const id of ids) {
const { piece, index } = byId.get(id)!;
const allocation = takeStock(stock, piece.length, !!piece.factoryRight, id, s);
result.cuts += Number(allocation.crosscut);
result.reusedCount += Number(allocation.reused);
result.pieces[index] = {
...piece,
stockId: stock.id,
reused: allocation.reused,
blankLength: allocation.blankLength,
cut: allocation.crosscut || piece.shaped || !!piece.ripEdge,
};
}
}
return compareLayouts(result, layout, s) < 0 ? result : layout;
}
function appendRow(
base: Layout,
cuts: CutPiece[],
@ -280,9 +408,13 @@ function appendRow(
pieces: [...base.pieces],
rows: [...base.rows],
cuts: base.cuts,
shapingCuts: base.shapingCuts,
ripCuts: base.ripCuts,
reusedCount: base.reusedCount,
patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s),
stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })),
stocks: base.stocks.map((stock) =>
stock.remaining > 0 ? { ...stock, pieceIds: [...stock.pieceIds] } : stock,
),
};
const pieceIds: string[] = [];
cuts.forEach((piece, column) => {
@ -309,6 +441,8 @@ function appendRow(
const ripEdge = ripTop && ripBottom ? 'both' : ripTop ? 'top' : ripBottom ? 'bottom' : null;
const passes = Number(allocation.crosscut) + Number(ripTop) + Number(ripBottom) + shapeCuts;
layout.cuts += passes;
layout.shapingCuts += shapeCuts;
layout.ripCuts += Number(ripTop) + Number(ripBottom);
layout.pieces.push({
id,
roomId: room.id,
@ -388,6 +522,104 @@ function randomPatternPenalty(
return penalty;
}
/** Reorder interchangeable full-width rectangular rows without adding any cutting operations. */
function improveRowOrder(layout: Layout, rooms: Room[], s: FlooringSettings): Layout {
if (s.pattern !== 'random' || layout.patternPenalty === 0) return layout;
const rows = [...layout.rows];
const byId = new Map(layout.pieces.map((piece) => [piece.id, piece]));
const moves = new Map<string, { row: number; shift: Point }>();
let improvement = 0;
for (const room of rooms) {
const positions = rows.flatMap((row, index) => (row.roomId === room.id ? [index] : []));
const original = positions.map((index) => rows[index]);
const order = [...original];
const shapes = new Map(
original.map((row) => {
const pieces = row.pieceIds.map((id) => byId.get(id)!);
const b = bounds(
pieces.flatMap((p) => p.polygon.map((point) => boardPoint(point, room.direction))),
);
const rectangular =
pieces.every((p) => !p.shaped && !p.ripEdge && close(p.width, s.width)) &&
close(
pieces.reduce((sum, p) => sum + p.length, 0),
b.maxX - b.minX,
);
return [row, { y: b.minY, key: rectangular ? `${round(b.minX)}:${round(b.maxX)}` : '' }];
}),
);
const scoreAt = (index: number) =>
randomPatternPenalty(
order.slice(Math.max(0, index - 4), index),
room.id,
order[index].offset,
s,
);
let attempts = 0;
for (let pass = 0; pass < 4; pass++) {
let best: { i: number; j: number; delta: number } | undefined;
for (let i = 0; i < order.length; i++) {
const key = shapes.get(order[i])!.key;
if (!key) continue;
for (let j = i + 1; j < order.length; j++) {
if (++attempts > 4096) break;
if (shapes.get(order[j])!.key !== key || close(order[i].offset, order[j].offset))
continue;
const affected = [
...new Set(
[i, j].flatMap((index) =>
Array.from({ length: Math.min(5, order.length - index) }, (_, n) => index + n),
),
),
];
const before = affected.reduce((sum, index) => sum + scoreAt(index), 0);
[order[i], order[j]] = [order[j], order[i]];
const valid = [i, i + 1, j, j + 1].every(
(index) =>
index === 0 ||
index >= order.length ||
staggerAllowed(order[index - 1].offset, order[index].offset, s),
);
const delta = valid
? affected.reduce((sum, index) => sum + scoreAt(index), 0) - before
: 0;
[order[i], order[j]] = [order[j], order[i]];
if (delta < (best?.delta ?? 0)) best = { i, j, delta };
}
}
if (!best) break;
[order[best.i], order[best.j]] = [order[best.j], order[best.i]];
improvement -= best.delta;
}
order.forEach((source, index) => {
const target = original[index];
if (source === target) return;
rows[positions[index]] = { ...source, index: target.index };
const shift = worldPoint(
{ x: 0, y: shapes.get(target)!.y - shapes.get(source)!.y },
room.direction,
);
source.pieceIds.forEach((id) => moves.set(id, { row: target.index, shift }));
});
}
if (!improvement) return layout;
return {
...layout,
rows,
patternPenalty: layout.patternPenalty - improvement,
pieces: layout.pieces.map((piece) => {
const move = moves.get(piece.id);
return move
? {
...piece,
row: move.row,
polygon: piece.polygon.map((p) => ({ x: p.x + move.shift.x, y: p.y + move.shift.y })),
}
: piece;
}),
};
}
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
function fraction(seed: string): number {
let hash = 2166136261;
@ -452,11 +684,16 @@ function candidatePhases(
return candidates.slice(0, 28);
}
function compareLayouts(a: Layout, b: Layout): number {
function compareLayouts(a: Layout, b: Layout, s: FlooringSettings): number {
return (
a.stocks.length - b.stocks.length ||
a.patternPenalty - b.patternPenalty ||
(s.pattern === 'random' && s.optimisation === 'random'
? a.patternPenalty - b.patternPenalty
: 0) ||
a.cuts - b.cuts ||
a.shapingCuts - b.shapingCuts ||
a.ripCuts - b.ripCuts ||
a.patternPenalty - b.patternPenalty ||
a.stocks.length - b.stocks.length ||
b.reusedCount - a.reusedCount ||
compareText(
a.rows.map((r) => r.offset.toFixed(3)).join(','),
@ -495,15 +732,23 @@ function validateSettings(s: FlooringSettings): string | null {
* Only full-width end remnants are reused; ripped strips are conservatively waste.
* This is a practical cutting plan, not a claim of a global packing optimum.
*/
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
function solveFlooring(
rooms: Room[],
s: FlooringSettings,
tolerance: number,
shapes: ShapeCache,
): Solution {
let layout: Layout = {
pieces: [],
rows: [],
stocks: [],
cuts: 0,
shapingCuts: 0,
ripCuts: 0,
reusedCount: 0,
patternPenalty: 0,
};
let finalists = [layout];
const issues: Issue[] = [];
const invalid = validateSettings(s);
if (invalid) issues.push({ severity: 'error', message: invalid });
@ -553,14 +798,22 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
let y = roomBounds.minY;
for (let row = 0; row < widths.length && beam.length; row++) {
const end = row === widths.length - 1 ? roomBounds.maxY : round(y + widths[row]);
const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end });
if (!rowPaths.length) {
y = end;
continue;
const key = `${room.id}:${widths[0]}:${row}`;
let cached = shapes.get(key);
if (!cached) {
const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end });
if (!rowPaths.length) {
y = end;
continue;
}
cached = {
shape: { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) },
cuts: new Map(),
};
shapes.set(key, cached);
}
const shape: RowShape = { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) };
const { shape, cuts: cache } = cached;
const alternatives: Layout[] = [];
const cache = new Map<number, CutPiece[] | null>();
for (const base of beam) {
const roomRows = base.rows.filter((r) => r.roomId === room.id);
const previousPhase = roomRows.at(-1)?.offset;
@ -586,26 +839,32 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate);
}
}
alternatives.sort(compareLayouts);
// Keep distinct phases/inventories; identical routes do not earn extra beam slots.
beam = alternatives
.filter(
(candidate, index) =>
index === 0 ||
candidate.rows.at(-1)?.offset !== alternatives[index - 1].rows.at(-1)?.offset ||
candidate.stocks
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
.join(',') !==
alternatives[index - 1].stocks
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
.join(','),
)
.slice(0, beamWidth);
alternatives.sort((a, b) => compareLayouts(a, b, s));
// Histories with different recent joints can have different future pattern costs.
const seen = new Set<string>();
beam = [];
for (const candidate of alternatives) {
const phases = candidate.rows
.filter((r) => r.roomId === room.id)
.slice(s.pattern === 'random' ? -4 : -1)
.map((r) => r.offset)
.join(',');
const inventory = candidate.stocks
.filter((stock) => stock.remaining >= s.minLength - EPSILON)
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
.sort()
.join(',');
const key = `${phases}|${inventory}`;
if (seen.has(key)) continue;
seen.add(key);
beam.push(candidate);
if (beam.length === beamWidth) break;
}
y = end;
}
if (beam.length) complete.push(...beam);
}
complete.sort(compareLayouts);
complete.sort((a, b) => compareLayouts(a, b, s));
if (!complete.length) {
issues.push({
severity: 'error',
@ -615,6 +874,13 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
continue;
}
layout = complete[0];
finalists = complete;
}
for (const candidate of finalists) {
const improved = improveRowOrder(repack(candidate, s), rooms, s);
if (compareLayouts(improved, layout, s) < 0) layout = improved;
}
for (const room of rooms) {
if (layout.pieces.some((p) => p.roomId === room.id && p.shaped))
issues.push({
severity: 'warning',
@ -622,6 +888,10 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
message: `${room.name}: angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`,
});
}
return { layout, issues };
}
function finishFlooring({ layout, issues }: Solution, s: FlooringSettings): FlooringPlan {
const coveredArea = layout.pieces.reduce((sum, piece) => sum + area(piece.polygon), 0);
const purchasedArea = layout.stocks.length ? layout.stocks.length * s.length * s.width : 0;
const wasteArea = Math.max(0, purchasedArea - coveredArea);

View file

@ -350,7 +350,12 @@ interface Packed {
}
/** Best-fit decreasing across all rooms, using one purchased board length. */
function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packed {
function pack(
source: SkirtingPiece[],
stockLength: number,
kerf: number,
exactTails = false,
): Packed {
const pieces = source.map((piece) => ({ ...piece }));
let pending = [...pieces].sort(
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
@ -360,6 +365,25 @@ function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packe
while (pending.length) {
let available = stockLength;
const assigned: SkirtingPiece[] = [];
if (exactTails && pending.length > 2) {
const head = pending[0];
const tail = stockLength - consumedLength(head, stockLength, kerf);
const options = pending.slice(1, 129).filter((piece) => fits(piece, tail));
for (const second of options) {
const remaining = tail - consumedLength(second, tail, kerf);
const third = options.find(
(piece) => piece !== second && Math.abs(piece.length - remaining) <= EPS,
);
if (!third) continue;
pending = [
head,
second,
third,
...pending.filter((piece) => piece !== head && piece !== second && piece !== third),
];
break;
}
}
for (const piece of pending) {
if (!fits(piece, available)) continue;
piece.blankLength = blankLength(piece, available);
@ -525,6 +549,7 @@ export function planSkirting(
piece.id = `${prefixes.get(piece.roomId)}${count}`;
}
candidates.push(pack(source, stockLength, settings.kerf));
candidates.push(pack(source, stockLength, settings.kerf, true));
};
// Compare full-stock, balanced, and offcut-led wall joints.
{
@ -540,8 +565,8 @@ export function planSkirting(
}
candidates.sort(
(a, b) =>
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
a.cuts - b.cuts ||
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
a.stocks.length - b.stocks.length ||
a.pieces.length - b.pieces.length,
);

View file

@ -25,6 +25,8 @@ export interface FlooringSettings {
width: number;
gap: number;
pattern: 'brick' | 'random';
/** Random flooring can prioritise fewer saw passes or less visual repetition. */
optimisation?: 'cuts' | 'random';
minOffset: number;
maxOffset: number;
minLength: number;

View file

@ -147,6 +147,11 @@ function flooring(value: unknown): FlooringSettings {
width: number(data.width, 'Board width'),
gap: number(data.gap, 'Expansion gap'),
pattern: choice(data.pattern, ['brick', 'random'], 'Flooring pattern'),
optimisation: choice(
data.optimisation === undefined ? 'cuts' : data.optimisation,
['cuts', 'random'] as const,
'Flooring optimisation',
),
minOffset: number(data.minOffset, 'Minimum offset'),
maxOffset: number(data.maxOffset, 'Maximum offset'),
minLength: number(data.minLength, 'Minimum board length'),
@ -198,6 +203,8 @@ function parseState(value: unknown): UrlState {
const roomId = string(view.roomId, 'Selected room');
const skirt = skirting(project.skirting);
const completed = completedBoards(project.completedBoards);
const previousOptimiser =
object(project.flooring, 'Flooring settings').optimisation === undefined;
return {
project: {
version: 1,
@ -206,7 +213,7 @@ function parseState(value: unknown): UrlState {
flooring: flooring(project.flooring),
skirting: skirt.settings,
tolerance: number(project.tolerance, 'Measurement tolerance'),
completedBoards: skirt.legacyCalculations ? [] : completed,
completedBoards: skirt.legacyCalculations || previousOptimiser ? [] : completed,
},
view: {
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
@ -362,9 +369,11 @@ function parseQuery(query: string): UrlState {
flooring: Object.fromEntries(
Object.entries(floor).map(([key, value]) => [
key,
typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
key === 'optimisation' && !params.has('flooring.optimisation')
? undefined
: typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
]),
),
skirting: {

View file

@ -2,10 +2,33 @@ import { useCallback, useEffect, useState } from 'react';
import type { UrlState } from './types';
import { decodeState, encodeState } from './url-state';
const readLocation = () => decodeState(window.location.search || window.location.hash);
export const PROJECT_STORAGE_KEY = 'floor-planner.project';
const readLocation = () => {
// The landing-page CTA opens the last project; it is not a project override.
const link =
window.location.search === '?planner' ? '' : window.location.search || window.location.hash;
if (link && link !== '?' && link !== '#') return decodeState(link);
try {
const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? '');
if (!saved.error) return saved;
} catch {
// Browser privacy settings can make localStorage unavailable.
}
return decodeState('');
};
const saveLocal = (query: string) => {
try {
window.localStorage.setItem(PROJECT_STORAGE_KEY, query);
} catch {
// Keep URL persistence working if storage is blocked or full.
}
};
const queryUrl = (query: string) => `${window.location.pathname}${query}`;
/** Readable query parameters are the durable source of truth. */
/** Persist every parameter in the URL and locally; explicit links take precedence. */
export function useProject() {
const [loaded, setLoaded] = useState(readLocation);
useEffect(() => {
@ -18,6 +41,7 @@ export function useProject() {
const query = encodeState(next.state);
if (query !== window.location.search || window.location.hash)
window.history.replaceState(null, '', queryUrl(query));
saveLocal(query);
} catch (error) {
next.error = error instanceof Error ? error.message : 'This plan could not be saved.';
}
@ -39,6 +63,7 @@ export function useProject() {
const query = encodeState(next);
if (query !== window.location.search || window.location.hash)
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
saveLocal(query);
const canonical = decodeState(query).state;
if (next.project === current.state.project) canonical.project = current.state.project;
else {