From 41d41bdebd55431d01dc04b8829f6ac3c219e06b Mon Sep 17 00:00:00 2001 From: Rohit Rajendran Date: Fri, 20 Mar 2026 16:43:43 -0400 Subject: [PATCH] Reorg and clean up --- AGENTS.md | 47 +++++++------ README.md | 11 +++- src/App.tsx | 10 +-- .../Canvas/{ => BoxElement}/BoxElement.tsx | 8 +-- .../DrawingCanvas.module.css | 0 .../{ => DrawingCanvas}/DrawingCanvas.tsx | 20 +++--- src/components/Canvas/{ => Grid}/Grid.tsx | 2 +- .../{ => MeasureOverlay}/MeasureOverlay.tsx | 4 +- .../MultiSelectBar.module.css | 0 .../{ => MultiSelectBar}/MultiSelectBar.tsx | 4 +- .../Canvas/{ => ScaleBar}/ScaleBar.module.css | 0 .../Canvas/{ => ScaleBar}/ScaleBar.tsx | 0 .../Canvas/{ => TopBar}/TopBar.module.css | 0 src/components/Canvas/{ => TopBar}/TopBar.tsx | 4 +- .../Canvas/{ => WallElement}/WallElement.tsx | 8 +-- .../FloorplanManager/FloorplanManager.tsx | 4 +- .../HelpOverlay}/HelpOverlay.test.tsx | 4 +- src/components/HelpOverlay/HelpOverlay.tsx | 2 +- .../{ => FtInInput}/FtInInput.module.css | 0 .../{ => FtInInput}/FtInInput.tsx | 2 +- .../PropertiesPanel/PropertiesPanel.tsx | 8 +-- src/components/Toolbar/Toolbar.tsx | 4 +- src/hooks/{ => useFocusTrap}/useFocusTrap.ts | 0 .../unit => hooks/useSnap}/useSnap.test.ts | 8 ++- src/hooks/{ => useSnap}/useSnap.ts | 4 +- .../useFloorplanStore}/undoRedo.test.ts | 2 +- .../useFloorplanStore.test.ts | 5 +- .../useFloorplanStore.ts | 11 +++- .../useToolStore}/useToolStore.test.ts | 2 +- src/store/{ => useToolStore}/useToolStore.ts | 2 +- src/types/index.ts | 1 + .../unit => utils/geometry}/geometry.test.ts | 2 +- src/utils/{ => geometry}/geometry.ts | 2 +- src/utils/storage.ts | 25 ------- .../unit => utils/storage}/storage.test.ts | 32 ++++++++- src/utils/storage/storage.ts | 66 +++++++++++++++++++ 36 files changed, 200 insertions(+), 104 deletions(-) rename src/components/Canvas/{ => BoxElement}/BoxElement.tsx (94%) rename src/components/Canvas/{ => DrawingCanvas}/DrawingCanvas.module.css (100%) rename src/components/Canvas/{ => DrawingCanvas}/DrawingCanvas.tsx (98%) rename src/components/Canvas/{ => Grid}/Grid.tsx (97%) rename src/components/Canvas/{ => MeasureOverlay}/MeasureOverlay.tsx (97%) rename src/components/Canvas/{ => MultiSelectBar}/MultiSelectBar.module.css (100%) rename src/components/Canvas/{ => MultiSelectBar}/MultiSelectBar.tsx (86%) rename src/components/Canvas/{ => ScaleBar}/ScaleBar.module.css (100%) rename src/components/Canvas/{ => ScaleBar}/ScaleBar.tsx (100%) rename src/components/Canvas/{ => TopBar}/TopBar.module.css (100%) rename src/components/Canvas/{ => TopBar}/TopBar.tsx (93%) rename src/components/Canvas/{ => WallElement}/WallElement.tsx (97%) rename src/{tests/unit => components/HelpOverlay}/HelpOverlay.test.tsx (97%) rename src/components/PropertiesPanel/{ => FtInInput}/FtInInput.module.css (100%) rename src/components/PropertiesPanel/{ => FtInInput}/FtInInput.tsx (95%) rename src/hooks/{ => useFocusTrap}/useFocusTrap.ts (100%) rename src/{tests/unit => hooks/useSnap}/useSnap.test.ts (97%) rename src/hooks/{ => useSnap}/useSnap.ts (97%) rename src/{tests/unit => store/useFloorplanStore}/undoRedo.test.ts (98%) rename src/{tests/unit => store/useFloorplanStore}/useFloorplanStore.test.ts (96%) rename src/store/{ => useFloorplanStore}/useFloorplanStore.ts (96%) rename src/{tests/unit => store/useToolStore}/useToolStore.test.ts (99%) rename src/store/{ => useToolStore}/useToolStore.ts (98%) rename src/{tests/unit => utils/geometry}/geometry.test.ts (99%) rename src/utils/{ => geometry}/geometry.ts (98%) delete mode 100644 src/utils/storage.ts rename src/{tests/unit => utils/storage}/storage.test.ts (59%) create mode 100644 src/utils/storage/storage.ts diff --git a/AGENTS.md b/AGENTS.md index 4fec05e..3b2461f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ Use this file as the source of truth for agent work in this repo. Keep it factua - Do not overwrite unrelated user changes in a dirty worktree. - Every code change must include tests in the same session. Update existing tests when behavior changes. - Before adding new selectors in tests, look for an existing `data-testid`. If one is missing, add one instead of relying on text or CSS classes. +- Keep component files grouped by component directory, for example `src/components/Canvas/TopBar/TopBar.tsx` with `TopBar.module.css`, instead of mixing multiple components in one folder. ## Stack @@ -53,30 +54,31 @@ npm run spell:check # cspell for src TS/TSX src/ App.tsx App shell; creates default plan on first load types/index.ts Point, Wall, Box, Element, ToolType, FloorPlan - utils/geometry.ts Coordinate conversion, snapping, dimensions, nudging - utils/storage.ts localStorage read/write helpers + utils/ + geometry/geometry.ts Coordinate conversion, snapping, dimensions, nudging + storage/storage.ts localStorage read/write helpers store/ - useFloorplanStore.ts Plans, elements, persistence, undo/redo history - useToolStore.ts Active tool, chain state, selection, measure, zoom, pan + useFloorplanStore/ Plans, elements, persistence, undo/redo history + useToolStore/ Active tool, chain state, selection, measure, zoom, pan hooks/ - useSnap.ts Endpoint/segment/axis/grid snapping - useFocusTrap.ts Overlay/dialog focus handling + useSnap/ Endpoint/segment/axis/grid snapping + useFocusTrap/ Overlay/dialog focus handling components/ Canvas/ - DrawingCanvas.tsx Main stage, pointer/keyboard/wheel handling - Grid.tsx Adaptive grid - WallElement.tsx Wall rendering and editing affordances - BoxElement.tsx Box rendering and dragging - MeasureOverlay.tsx Measurement overlay for measure tool - MultiSelectBar.tsx Actions for multi-selection - ScaleBar.tsx Scale indicator - TopBar.tsx Plan name and plan manager entry point + DrawingCanvas/ Main stage, pointer/keyboard/wheel handling + Grid/ Adaptive grid + WallElement/ Wall rendering and editing affordances + BoxElement/ Box rendering and dragging + MeasureOverlay/ Measurement overlay for measure tool + MultiSelectBar/ Actions for multi-selection + ScaleBar/ Scale indicator + TopBar/ Plan name and plan manager entry point layout.ts Overlay/mobile layout constants Toolbar/ Tool switcher, undo/redo, help PropertiesPanel/ Single-selection editing + PropertiesPanel/FtInInput/ Feet-and-inches field FloorplanManager/ Create, rename, delete, switch plans HelpOverlay/ Help modal shown on first visit -src/tests/unit/ Unit tests e2e/ Playwright scenarios ``` @@ -105,7 +107,7 @@ type Box = { type ToolType = 'select' | 'wall' | 'box' | 'measure'; ``` -`FloorPlan` stores `id`, `name`, timestamps, and `elements`. +`FloorPlan` stores `id`, `version`, `name`, timestamps, and `elements`. ## Core Invariants @@ -127,7 +129,7 @@ Snap priority is: 3. Axis lock from the active wall chain origin 4. Grid -This logic lives in `src/hooks/useSnap.ts`. Preserve that ordering unless the task explicitly changes snapping behavior and tests are updated with it. +This logic lives in `src/hooks/useSnap/useSnap.ts`. Preserve that ordering unless the task explicitly changes snapping behavior and tests are updated with it. ### Stage rendering @@ -142,10 +144,13 @@ This logic lives in `src/hooks/useSnap.ts`. Preserve that ordering unless the ta ### Persistence -- Storage helpers are in `src/utils/storage.ts`. +- Storage helpers are in `src/utils/storage/storage.ts`. +- Each persisted plan carries a schema `version`. - Floor plans persist on mutating store actions. - Undo/redo history is session-only and not persisted. - Corrupt or missing storage data should degrade safely to empty/default state. +- If a change would break compatibility with existing persisted plan data, bump the plan version constant and add a migration path for older plans in `loadFloorPlans()`. +- Do not ship a breaking persisted-data change without tests that cover both the legacy load path and the migrated current shape. ## Current Tooling and Behaviors @@ -174,8 +179,8 @@ Tests are mandatory for every code change. | Change type | Test location | |---|---| -| Geometry, parsing, conversion, snapping helpers | `src/tests/unit/geometry.test.ts` or `src/tests/unit/useSnap.test.ts` | -| Store mutations, persistence, undo/redo | `src/tests/unit/useFloorplanStore.test.ts` or `src/tests/unit/useToolStore.test.ts` | +| Geometry, parsing, conversion, snapping helpers | Co-located spec next to the source file, for example `src/utils/geometry/geometry.test.ts` or `src/hooks/useSnap/useSnap.test.ts` | +| Store mutations, persistence, undo/redo | Co-located spec next to the store file, for example `src/store/useFloorplanStore/useFloorplanStore.test.ts` or `src/store/useToolStore/useToolStore.test.ts` | | Drawing interactions, keyboard handling, cancel flows, selection, mobile UI behavior | `e2e/floorplan.spec.ts` or a new `e2e/*.spec.ts` | ### Unit test expectations @@ -211,7 +216,7 @@ Keep these covered: Typical verification choices: -- Geometry/store-only change: `npm run test -- src/tests/unit/...` +- Geometry/store-only change: `npm run test -- src/path/to/file.test.ts` - UI interaction change: `npm run test:e2e -- e2e/...` - Cross-cutting change: `npm run test`, then `npm run build` diff --git a/README.md b/README.md index 3bddd75..967b184 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ SnapDraft stores plan data in world coordinates measured in feet. Rendering is h The app starts with a default plan on first load. If a saved plan already has content, the canvas fits that content on open. +Persisted floor plans also carry a schema `version`. When a future change would break compatibility with existing saved plan data, the plan version must be bumped and `src/utils/storage/storage.ts` must include a migration path so older plans still load into the current shape. + ## Tools and Shortcuts | Tool | Shortcut | Use | @@ -91,7 +93,9 @@ Dimension fields accept common architectural shorthand: ## Testing -Unit tests live under `src/tests/unit/`. End-to-end tests live under `e2e/`. +Unit tests are co-located with the files they cover under `src/`. End-to-end tests live under `e2e/`. + +Component source is also grouped by component directory, for example `src/components/Canvas/TopBar/TopBar.tsx` or `src/components/Toolbar/Toolbar.tsx` with colocated CSS and tests, instead of mixing many components in one folder. Typical workflows: @@ -129,10 +133,9 @@ GitHub Actions runs: ```text src/ - components/ UI and canvas components + components/ UI and canvas components, grouped into per-component folders hooks/ Shared hooks such as snapping and focus management store/ Zustand stores for floor plan data and tool state - tests/unit/ Unit tests types/ Shared TypeScript models utils/ Geometry and storage helpers e2e/ Playwright tests @@ -141,5 +144,7 @@ e2e/ Playwright tests ## Contributing Notes - Keep persisted geometry in feet, not pixels. +- Keep persisted plan schema changes backward-compatible when possible. +- If you make a breaking change to persisted plan data, bump the plan version and add a tested migration path in `src/utils/storage/storage.ts`. - Use `stage.getRelativePointerPosition()` for world-coordinate math. - Prefer `data-testid` selectors for UI tests. diff --git a/src/App.tsx b/src/App.tsx index 0fbc621..0f65fe4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react'; -import { DrawingCanvas } from './components/Canvas/DrawingCanvas'; +import { DrawingCanvas } from './components/Canvas/DrawingCanvas/DrawingCanvas'; import { Toolbar } from './components/Toolbar/Toolbar'; -import { TopBar } from './components/Canvas/TopBar'; +import { TopBar } from './components/Canvas/TopBar/TopBar'; import { PropertiesPanel } from './components/PropertiesPanel/PropertiesPanel'; import { HelpOverlay } from './components/HelpOverlay/HelpOverlay'; -import { ScaleBar } from './components/Canvas/ScaleBar'; -import { MultiSelectBar } from './components/Canvas/MultiSelectBar'; -import { useFloorplanStore } from './store/useFloorplanStore'; +import { ScaleBar } from './components/Canvas/ScaleBar/ScaleBar'; +import { MultiSelectBar } from './components/Canvas/MultiSelectBar/MultiSelectBar'; +import { useFloorplanStore } from './store/useFloorplanStore/useFloorplanStore'; import styles from './App.module.css'; export default function App() { diff --git a/src/components/Canvas/BoxElement.tsx b/src/components/Canvas/BoxElement/BoxElement.tsx similarity index 94% rename from src/components/Canvas/BoxElement.tsx rename to src/components/Canvas/BoxElement/BoxElement.tsx index b15c4f0..524ad08 100644 --- a/src/components/Canvas/BoxElement.tsx +++ b/src/components/Canvas/BoxElement/BoxElement.tsx @@ -1,10 +1,10 @@ import { useRef } from 'react'; import { Group, Rect, Text, Circle, Line } from 'react-konva'; import type Konva from 'konva'; -import { useToolStore } from '../../store/useToolStore'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; -import type { Box } from '../../types'; -import { ftToPx, pxToFt, formatFeet } from '../../utils/geometry'; +import { useToolStore } from '../../../store/useToolStore/useToolStore'; +import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore'; +import type { Box } from '../../../types'; +import { ftToPx, pxToFt, formatFeet } from '../../../utils/geometry/geometry'; const HANDLE_OFFSET_PX = 22; diff --git a/src/components/Canvas/DrawingCanvas.module.css b/src/components/Canvas/DrawingCanvas/DrawingCanvas.module.css similarity index 100% rename from src/components/Canvas/DrawingCanvas.module.css rename to src/components/Canvas/DrawingCanvas/DrawingCanvas.module.css diff --git a/src/components/Canvas/DrawingCanvas.tsx b/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx similarity index 98% rename from src/components/Canvas/DrawingCanvas.tsx rename to src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx index e0a5e0f..2a0eace 100644 --- a/src/components/Canvas/DrawingCanvas.tsx +++ b/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx @@ -2,13 +2,13 @@ import { useRef, useState, useEffect, useCallback } from 'react'; import { Stage, Layer, Line, Rect, Circle, Text, Group } from 'react-konva'; import type Konva from 'konva'; import { nanoid } from 'nanoid'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; -import { useToolStore } from '../../store/useToolStore'; -import { useSnap } from '../../hooks/useSnap'; -import { Grid } from './Grid'; -import { WallElement } from './WallElement'; -import { BoxElement } from './BoxElement'; -import { MeasureOverlay } from './MeasureOverlay'; +import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore'; +import { useToolStore } from '../../../store/useToolStore/useToolStore'; +import { useSnap } from '../../../hooks/useSnap/useSnap'; +import { Grid } from '../Grid/Grid'; +import { WallElement } from '../WallElement/WallElement'; +import { BoxElement } from '../BoxElement/BoxElement'; +import { MeasureOverlay } from '../MeasureOverlay/MeasureOverlay'; import styles from './DrawingCanvas.module.css'; import { ftToPx, @@ -20,14 +20,14 @@ import { NUDGE_FT, FINE_NUDGE_FT, getWallSnapIncrement, -} from '../../utils/geometry'; -import type { Point, Element } from '../../types'; +} from '../../../utils/geometry/geometry'; +import type { Point, Element } from '../../../types'; import { FIT_CONTENT_PADDING_PX, MOBILE_OVERLAY_CLEARANCE_PX, MOBILE_TOOLBAR_INSET_PX, shouldUseMobileOverlayLayout, -} from './layout'; +} from '../layout'; const DRAG_THRESHOLD_FT = 0.3; const CLOSE_CHAIN_RADIUS_FT = 0.5; diff --git a/src/components/Canvas/Grid.tsx b/src/components/Canvas/Grid/Grid.tsx similarity index 97% rename from src/components/Canvas/Grid.tsx rename to src/components/Canvas/Grid/Grid.tsx index 1f50b95..a88dfe1 100644 --- a/src/components/Canvas/Grid.tsx +++ b/src/components/Canvas/Grid/Grid.tsx @@ -1,6 +1,6 @@ import { Line, Rect } from 'react-konva'; import { useMemo } from 'react'; -import { PIXELS_PER_FOOT } from '../../utils/geometry'; +import { PIXELS_PER_FOOT } from '../../../utils/geometry/geometry'; type Props = { width: number; diff --git a/src/components/Canvas/MeasureOverlay.tsx b/src/components/Canvas/MeasureOverlay/MeasureOverlay.tsx similarity index 97% rename from src/components/Canvas/MeasureOverlay.tsx rename to src/components/Canvas/MeasureOverlay/MeasureOverlay.tsx index 38fcaaf..1016190 100644 --- a/src/components/Canvas/MeasureOverlay.tsx +++ b/src/components/Canvas/MeasureOverlay/MeasureOverlay.tsx @@ -1,6 +1,6 @@ import { Group, Line, Rect, Text } from 'react-konva'; -import { distance, formatFeet } from '../../utils/geometry'; -import type { Point } from '../../types'; +import { distance, formatFeet } from '../../../utils/geometry/geometry'; +import type { Point } from '../../../types'; type Props = { start: Point; diff --git a/src/components/Canvas/MultiSelectBar.module.css b/src/components/Canvas/MultiSelectBar/MultiSelectBar.module.css similarity index 100% rename from src/components/Canvas/MultiSelectBar.module.css rename to src/components/Canvas/MultiSelectBar/MultiSelectBar.module.css diff --git a/src/components/Canvas/MultiSelectBar.tsx b/src/components/Canvas/MultiSelectBar/MultiSelectBar.tsx similarity index 86% rename from src/components/Canvas/MultiSelectBar.tsx rename to src/components/Canvas/MultiSelectBar/MultiSelectBar.tsx index dc2c8ea..4724f82 100644 --- a/src/components/Canvas/MultiSelectBar.tsx +++ b/src/components/Canvas/MultiSelectBar/MultiSelectBar.tsx @@ -1,5 +1,5 @@ -import { useToolStore } from '../../store/useToolStore'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; +import { useToolStore } from '../../../store/useToolStore/useToolStore'; +import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore'; import styles from './MultiSelectBar.module.css'; export function MultiSelectBar() { diff --git a/src/components/Canvas/ScaleBar.module.css b/src/components/Canvas/ScaleBar/ScaleBar.module.css similarity index 100% rename from src/components/Canvas/ScaleBar.module.css rename to src/components/Canvas/ScaleBar/ScaleBar.module.css diff --git a/src/components/Canvas/ScaleBar.tsx b/src/components/Canvas/ScaleBar/ScaleBar.tsx similarity index 100% rename from src/components/Canvas/ScaleBar.tsx rename to src/components/Canvas/ScaleBar/ScaleBar.tsx diff --git a/src/components/Canvas/TopBar.module.css b/src/components/Canvas/TopBar/TopBar.module.css similarity index 100% rename from src/components/Canvas/TopBar.module.css rename to src/components/Canvas/TopBar/TopBar.module.css diff --git a/src/components/Canvas/TopBar.tsx b/src/components/Canvas/TopBar/TopBar.tsx similarity index 93% rename from src/components/Canvas/TopBar.tsx rename to src/components/Canvas/TopBar/TopBar.tsx index a01ed43..6373600 100644 --- a/src/components/Canvas/TopBar.tsx +++ b/src/components/Canvas/TopBar/TopBar.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from 'react'; import { ChevronDown } from 'lucide-react'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; -import { FloorplanManager } from '../FloorplanManager/FloorplanManager'; +import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore'; +import { FloorplanManager } from '../../FloorplanManager/FloorplanManager'; import styles from './TopBar.module.css'; export function TopBar() { diff --git a/src/components/Canvas/WallElement.tsx b/src/components/Canvas/WallElement/WallElement.tsx similarity index 97% rename from src/components/Canvas/WallElement.tsx rename to src/components/Canvas/WallElement/WallElement.tsx index 7f01bea..b9f4edd 100644 --- a/src/components/Canvas/WallElement.tsx +++ b/src/components/Canvas/WallElement/WallElement.tsx @@ -1,9 +1,9 @@ import { useRef, useState } from 'react'; import { Line, Circle } from 'react-konva'; import type Konva from 'konva'; -import { useToolStore } from '../../store/useToolStore'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; -import type { Wall, Point } from '../../types'; +import { useToolStore } from '../../../store/useToolStore/useToolStore'; +import { useFloorplanStore } from '../../../store/useFloorplanStore/useFloorplanStore'; +import type { Wall, Point } from '../../../types'; import { ftToPx, pxToFt, @@ -12,7 +12,7 @@ import { distance, SNAP_RADIUS_FT, getWallSnapIncrement, -} from '../../utils/geometry'; +} from '../../../utils/geometry/geometry'; function moveOtherSelected( node: Konva.Node, diff --git a/src/components/FloorplanManager/FloorplanManager.tsx b/src/components/FloorplanManager/FloorplanManager.tsx index 5a89aff..4467e8f 100644 --- a/src/components/FloorplanManager/FloorplanManager.tsx +++ b/src/components/FloorplanManager/FloorplanManager.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from 'react'; import { X, Pencil, Plus } from 'lucide-react'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; -import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanStore'; +import { useFocusTrap } from '../../hooks/useFocusTrap/useFocusTrap'; import styles from './FloorplanManager.module.css'; type Props = { diff --git a/src/tests/unit/HelpOverlay.test.tsx b/src/components/HelpOverlay/HelpOverlay.test.tsx similarity index 97% rename from src/tests/unit/HelpOverlay.test.tsx rename to src/components/HelpOverlay/HelpOverlay.test.tsx index aff80fa..4c4c9b8 100644 --- a/src/tests/unit/HelpOverlay.test.tsx +++ b/src/components/HelpOverlay/HelpOverlay.test.tsx @@ -1,8 +1,8 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { HelpOverlay } from '../../components/HelpOverlay/HelpOverlay'; -import styles from '../../components/HelpOverlay/HelpOverlay.module.css'; +import { HelpOverlay } from './HelpOverlay'; +import styles from './HelpOverlay.module.css'; describe('HelpOverlay', () => { let container: HTMLDivElement; diff --git a/src/components/HelpOverlay/HelpOverlay.tsx b/src/components/HelpOverlay/HelpOverlay.tsx index 602bad5..2b87814 100644 --- a/src/components/HelpOverlay/HelpOverlay.tsx +++ b/src/components/HelpOverlay/HelpOverlay.tsx @@ -1,6 +1,6 @@ import { useRef } from 'react'; import { X } from 'lucide-react'; -import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { useFocusTrap } from '../../hooks/useFocusTrap/useFocusTrap'; import styles from './HelpOverlay.module.css'; type Props = { diff --git a/src/components/PropertiesPanel/FtInInput.module.css b/src/components/PropertiesPanel/FtInInput/FtInInput.module.css similarity index 100% rename from src/components/PropertiesPanel/FtInInput.module.css rename to src/components/PropertiesPanel/FtInInput/FtInInput.module.css diff --git a/src/components/PropertiesPanel/FtInInput.tsx b/src/components/PropertiesPanel/FtInInput/FtInInput.tsx similarity index 95% rename from src/components/PropertiesPanel/FtInInput.tsx rename to src/components/PropertiesPanel/FtInInput/FtInInput.tsx index 245ed27..06810d7 100644 --- a/src/components/PropertiesPanel/FtInInput.tsx +++ b/src/components/PropertiesPanel/FtInInput/FtInInput.tsx @@ -1,5 +1,5 @@ import { useState, useId } from 'react'; -import { formatFeet, parseFtIn } from '../../utils/geometry'; +import { formatFeet, parseFtIn } from '../../../utils/geometry/geometry'; import styles from './FtInInput.module.css'; type Props = { diff --git a/src/components/PropertiesPanel/PropertiesPanel.tsx b/src/components/PropertiesPanel/PropertiesPanel.tsx index d760a00..4ebc25c 100644 --- a/src/components/PropertiesPanel/PropertiesPanel.tsx +++ b/src/components/PropertiesPanel/PropertiesPanel.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; -import { useToolStore } from '../../store/useToolStore'; +import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanStore'; +import { useToolStore } from '../../store/useToolStore/useToolStore'; import type { Box, Element, Wall } from '../../types'; -import { collectConnectedWallIds, formatFeet } from '../../utils/geometry'; -import { FtInInput } from './FtInInput'; +import { collectConnectedWallIds, formatFeet } from '../../utils/geometry/geometry'; +import { FtInInput } from './FtInInput/FtInInput'; import styles from './PropertiesPanel.module.css'; function WallProperties({ wall, onDelete }: { wall: Wall; onDelete: () => void }) { diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx index d3a4d55..6d03d9c 100644 --- a/src/components/Toolbar/Toolbar.tsx +++ b/src/components/Toolbar/Toolbar.tsx @@ -8,8 +8,8 @@ import { Redo2, HelpCircle, } from 'lucide-react'; -import { useToolStore } from '../../store/useToolStore'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; +import { useToolStore } from '../../store/useToolStore/useToolStore'; +import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanStore'; import type { ToolType } from '../../types'; import styles from './Toolbar.module.css'; diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap/useFocusTrap.ts similarity index 100% rename from src/hooks/useFocusTrap.ts rename to src/hooks/useFocusTrap/useFocusTrap.ts diff --git a/src/tests/unit/useSnap.test.ts b/src/hooks/useSnap/useSnap.test.ts similarity index 97% rename from src/tests/unit/useSnap.test.ts rename to src/hooks/useSnap/useSnap.test.ts index 8ff4462..5cf8070 100644 --- a/src/tests/unit/useSnap.test.ts +++ b/src/hooks/useSnap/useSnap.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { collectEndpoints, findNearestOnSegments, axisSnap } from '../../hooks/useSnap'; -import { findNearestEndpoint, WALL_SNAP_FT, FINE_WALL_SNAP_FT } from '../../utils/geometry'; +import { collectEndpoints, findNearestOnSegments, axisSnap } from './useSnap'; +import { + findNearestEndpoint, + WALL_SNAP_FT, + FINE_WALL_SNAP_FT, +} from '../../utils/geometry/geometry'; import type { Element } from '../../types'; const wall = (points: { x: number; y: number }[]): Element => ({ diff --git a/src/hooks/useSnap.ts b/src/hooks/useSnap/useSnap.ts similarity index 97% rename from src/hooks/useSnap.ts rename to src/hooks/useSnap/useSnap.ts index 85e109f..d6fef57 100644 --- a/src/hooks/useSnap.ts +++ b/src/hooks/useSnap/useSnap.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import type { Point, Element } from '../types'; +import type { Point, Element } from '../../types'; import { snapPointToGrid, snapToGrid, @@ -8,7 +8,7 @@ import { distance, SNAP_RADIUS_FT, GRID_SNAP_FT, -} from '../utils/geometry'; +} from '../../utils/geometry/geometry'; function collectEndpoints(elements: Element[]): Point[] { const pts: Point[] = []; diff --git a/src/tests/unit/undoRedo.test.ts b/src/store/useFloorplanStore/undoRedo.test.ts similarity index 98% rename from src/tests/unit/undoRedo.test.ts rename to src/store/useFloorplanStore/undoRedo.test.ts index 336d515..eb764bb 100644 --- a/src/tests/unit/undoRedo.test.ts +++ b/src/store/useFloorplanStore/undoRedo.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; +import { useFloorplanStore } from './useFloorplanStore'; import type { Element } from '../../types'; const wall = (id: string): Element => ({ diff --git a/src/tests/unit/useFloorplanStore.test.ts b/src/store/useFloorplanStore/useFloorplanStore.test.ts similarity index 96% rename from src/tests/unit/useFloorplanStore.test.ts rename to src/store/useFloorplanStore/useFloorplanStore.test.ts index d789374..18ccf9e 100644 --- a/src/tests/unit/useFloorplanStore.test.ts +++ b/src/store/useFloorplanStore/useFloorplanStore.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useFloorplanStore } from '../../store/useFloorplanStore'; +import { useFloorplanStore } from './useFloorplanStore'; +import { FLOORPLAN_VERSION } from '../../utils/storage/storage'; import type { Element } from '../../types'; const wall = (id = 'w1'): Element => ({ @@ -37,6 +38,7 @@ describe('createPlan', () => { const id = useFloorplanStore.getState().createPlan(); const plan = useFloorplanStore.getState().plans.find((p) => p.id === id); expect(plan).toBeDefined(); + expect(plan!.version).toBe(FLOORPLAN_VERSION); expect(plan!.name).toBe('Untitled Plan'); expect(plan!.elements).toHaveLength(0); }); @@ -223,6 +225,7 @@ describe('persistence to localStorage', () => { const stored = localStorage.getItem('snapdraft_floorplans'); expect(stored).not.toBeNull(); const parsed = JSON.parse(stored!); + expect(parsed.find((p: { id: string }) => p.id === id)?.version).toBe(FLOORPLAN_VERSION); expect(parsed.find((p: { id: string }) => p.id === id)?.name).toBe('Persisted'); }); diff --git a/src/store/useFloorplanStore.ts b/src/store/useFloorplanStore/useFloorplanStore.ts similarity index 96% rename from src/store/useFloorplanStore.ts rename to src/store/useFloorplanStore/useFloorplanStore.ts index ff02fd9..bc25e97 100644 --- a/src/store/useFloorplanStore.ts +++ b/src/store/useFloorplanStore/useFloorplanStore.ts @@ -1,7 +1,13 @@ import { create } from 'zustand'; import { nanoid } from 'nanoid'; -import type { FloorPlan, Element, Point } from '../types'; -import { loadFloorPlans, saveFloorPlans, loadActiveId, saveActiveId } from '../utils/storage'; +import type { FloorPlan, Element, Point } from '../../types'; +import { + FLOORPLAN_VERSION, + loadFloorPlans, + saveFloorPlans, + loadActiveId, + saveActiveId, +} from '../../utils/storage/storage'; const MAX_HISTORY = 50; @@ -128,6 +134,7 @@ export const useFloorplanStore = create((set, get) => ({ const id = nanoid(); const plan: FloorPlan = { id, + version: FLOORPLAN_VERSION, name, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/src/tests/unit/useToolStore.test.ts b/src/store/useToolStore/useToolStore.test.ts similarity index 99% rename from src/tests/unit/useToolStore.test.ts rename to src/store/useToolStore/useToolStore.test.ts index 8446c5e..b5f2578 100644 --- a/src/tests/unit/useToolStore.test.ts +++ b/src/store/useToolStore/useToolStore.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useToolStore } from '../../store/useToolStore'; +import { useToolStore } from './useToolStore'; beforeEach(() => { // Reset to initial state before each test diff --git a/src/store/useToolStore.ts b/src/store/useToolStore/useToolStore.ts similarity index 98% rename from src/store/useToolStore.ts rename to src/store/useToolStore/useToolStore.ts index c5915ae..64fd810 100644 --- a/src/store/useToolStore.ts +++ b/src/store/useToolStore/useToolStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { ToolType, Point } from '../types'; +import type { ToolType, Point } from '../../types'; type ToolStore = { activeTool: ToolType; diff --git a/src/types/index.ts b/src/types/index.ts index d4c0c78..9d737d9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,7 @@ export type ToolType = 'select' | 'wall' | 'box' | 'measure'; export type FloorPlan = { id: string; + version: number; name: string; createdAt: string; updatedAt: string; diff --git a/src/tests/unit/geometry.test.ts b/src/utils/geometry/geometry.test.ts similarity index 99% rename from src/tests/unit/geometry.test.ts rename to src/utils/geometry/geometry.test.ts index 66381a9..2410a97 100644 --- a/src/tests/unit/geometry.test.ts +++ b/src/utils/geometry/geometry.test.ts @@ -17,7 +17,7 @@ import { FINE_WALL_SNAP_FT, getWallSnapIncrement, snapWallPoint, -} from '../../utils/geometry'; +} from './geometry'; import type { Element } from '../../types'; describe('ftToPx / pxToFt', () => { diff --git a/src/utils/geometry.ts b/src/utils/geometry/geometry.ts similarity index 98% rename from src/utils/geometry.ts rename to src/utils/geometry/geometry.ts index 37036e8..fdf256b 100644 --- a/src/utils/geometry.ts +++ b/src/utils/geometry/geometry.ts @@ -1,4 +1,4 @@ -import type { Element, Point } from '../types'; +import type { Element, Point } from '../../types'; export const PIXELS_PER_FOOT = 40; export const SNAP_RADIUS_FT = 0.4; diff --git a/src/utils/storage.ts b/src/utils/storage.ts deleted file mode 100644 index 6cd1670..0000000 --- a/src/utils/storage.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { FloorPlan } from '../types'; - -const STORAGE_KEY = 'snapdraft_floorplans'; -const ACTIVE_KEY = 'snapdraft_active'; - -export function loadFloorPlans(): FloorPlan[] { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : []; - } catch { - return []; - } -} - -export function saveFloorPlans(plans: FloorPlan[]): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(plans)); -} - -export function loadActiveId(): string | null { - return localStorage.getItem(ACTIVE_KEY); -} - -export function saveActiveId(id: string): void { - localStorage.setItem(ACTIVE_KEY, id); -} diff --git a/src/tests/unit/storage.test.ts b/src/utils/storage/storage.test.ts similarity index 59% rename from src/tests/unit/storage.test.ts rename to src/utils/storage/storage.test.ts index 5fc272c..4133dbf 100644 --- a/src/tests/unit/storage.test.ts +++ b/src/utils/storage/storage.test.ts @@ -1,9 +1,16 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { loadFloorPlans, saveFloorPlans, loadActiveId, saveActiveId } from '../../utils/storage'; +import { + FLOORPLAN_VERSION, + loadFloorPlans, + saveFloorPlans, + loadActiveId, + saveActiveId, +} from './storage'; import type { FloorPlan } from '../../types'; const mockPlan: FloorPlan = { id: 'test-1', + version: FLOORPLAN_VERSION, name: 'Test Plan', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', @@ -24,10 +31,33 @@ describe('loadFloorPlans', () => { expect(loadFloorPlans()).toEqual([mockPlan]); }); + it('upgrades legacy plans without a per-plan version', () => { + const legacyPlan = { + id: mockPlan.id, + name: mockPlan.name, + createdAt: mockPlan.createdAt, + updatedAt: mockPlan.updatedAt, + elements: mockPlan.elements, + }; + localStorage.setItem('snapdraft_floorplans', JSON.stringify([legacyPlan])); + + expect(loadFloorPlans()).toEqual([mockPlan]); + expect(JSON.parse(localStorage.getItem('snapdraft_floorplans')!)).toEqual([mockPlan]); + }); + it('returns empty array on corrupt data', () => { localStorage.setItem('snapdraft_floorplans', 'not-json{{{'); expect(loadFloorPlans()).toEqual([]); }); + + it('ignores plans with unsupported versions', () => { + localStorage.setItem( + 'snapdraft_floorplans', + JSON.stringify([{ ...mockPlan, version: FLOORPLAN_VERSION + 1 }]), + ); + + expect(loadFloorPlans()).toEqual([]); + }); }); describe('saveFloorPlans', () => { diff --git a/src/utils/storage/storage.ts b/src/utils/storage/storage.ts new file mode 100644 index 0000000..b7d88c1 --- /dev/null +++ b/src/utils/storage/storage.ts @@ -0,0 +1,66 @@ +import type { FloorPlan } from '../../types'; + +const STORAGE_KEY = 'snapdraft_floorplans'; +const ACTIVE_KEY = 'snapdraft_active'; +export const FLOORPLAN_VERSION = 1; + +type LegacyFloorPlan = Omit & { + version?: unknown; +}; + +function normalizeFloorPlan(plan: LegacyFloorPlan): FloorPlan | null { + if (typeof plan !== 'object' || plan === null) return null; + + if (plan.version === undefined) { + return { ...plan, version: FLOORPLAN_VERSION }; + } + + if (plan.version === FLOORPLAN_VERSION) { + return plan as FloorPlan; + } + + return null; +} + +export function loadFloorPlans(): FloorPlan[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + + const rawPlans = parsed; + + const plans = rawPlans + .map((plan) => normalizeFloorPlan(plan as LegacyFloorPlan)) + .filter((plan): plan is FloorPlan => plan !== null); + + const needsMigration = rawPlans.some( + (plan) => + typeof plan === 'object' && + plan !== null && + (!('version' in plan) || (plan as { version?: unknown }).version === undefined), + ); + + if (needsMigration) { + saveFloorPlans(plans); + } + + return plans; + } catch { + return []; + } +} + +export function saveFloorPlans(plans: FloorPlan[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(plans)); +} + +export function loadActiveId(): string | null { + return localStorage.getItem(ACTIVE_KEY); +} + +export function saveActiveId(id: string): void { + localStorage.setItem(ACTIVE_KEY, id); +}