A single-page sticky-notes web app. Click and drag on the board to create a note of a chosen size and position; drag notes to move them, drag the corner to resize, and drop a note on the trash zone to delete it. Notes persist across reloads.
Built with Vite + React + TypeScript and no UI or drag-and-drop libraries — the interaction (drag-to-create, move, resize, trash-drop) is engineered by hand on top of plain mouse events, which is the point of the exercise.
All four required behaviors plus four optional bonuses:
- Create a note of a given size at a given position (click-drag on empty board).
- Move a note by dragging its body.
- Resize a note by dragging its bottom-right corner.
- Delete a note by dragging it onto the trash zone.
- Bonus: inline text editing.
- Bonus: bring-to-front on overlap (clicking/selecting a note raises it).
- Bonus: persistence across reloads (mock async REST API over
localStorage). - Bonus: five note colors.
Requires Node.js 18+ and pnpm.
pnpm install # install dependencies
pnpm dev # start the dev server (http://localhost:5173)
pnpm test # run the test suite once
pnpm build # type-check + production build
pnpm exec tsc -b # type-check onlyDesktop only, minimum resolution 1024×768. Targets the latest Chrome, Firefox, and Edge (Windows/macOS).
State is centralized in a pure reducer. All note state transitions live in
src/state/notesReducer.ts, implemented as a pure function with no DOM access
and no side effects. Every feature — add, move, resize, delete, edit text,
bring-to-front, load — is a reducer action, never an ad-hoc mutation scattered
across components. Keeping the reducer pure makes it trivial to unit-test in
isolation (8 tests cover every action and its edge cases) and keeps the
single-source-of-truth guarantee honest: components dispatch intents, the reducer
decides the next state.
Dragging and resizing are hand-rolled, and tuned for performance. Two custom
hooks, useDraggable and useResizable, listen for mousemove/mouseup on
window (not the dragged element) so a fast cursor can't outrun the gesture. The
load-bearing decision is that intermediate drag frames live in local component
state, and the global reducer is dispatched exactly once — on mouseup. Without
this, every mousemove would re-render the entire notes list. Combined with
React.memo on the StickyNote component and stable callbacks, moving one note
does not re-render its siblings. Trash deletion is also hand-rolled: while a note
is dragged, its board-local rectangle is tested for overlap with the trash zone's
getBoundingClientRect() each frame, so the zone highlights on hover and the
note is deleted only on drop.
Persistence is isolated behind one seam. src/services/notesApi.ts is the
only module that touches localStorage, and it exposes a small asynchronous API
(fetchNotes / saveNotes) with simulated latency and a configurable failure
rate. Because the rest of the app talks only to that interface, swapping in a
real backend means rewriting that one file and nothing else. App loads notes
once on mount, mounts the interactive board only after the load resolves (so a
note created during the load window can't be silently overwritten), and debounces
saves after each change, surfacing load/save status in the toolbar.
src/
types/note.ts Note domain types + color palette + min dimensions
state/notesReducer.ts pure reducer (single source of truth)
services/notesApi.ts mock async REST API over localStorage (persistence seam)
hooks/
useDraggable.ts drag-to-move
useResizable.ts drag-to-resize
utils/geometry.ts rectangle overlap / board-local coordinate helpers
components/
Board.tsx create-by-drag, trash-overlap detection, wiring
StickyNote.tsx memoized note (move/resize/edit/delete)
TrashZone.tsx drop target with active highlight
__tests__/
notesReducer.test.ts 8 reducer unit tests
StickyNote.test.tsx 3 component tests (incl. an end-to-end drag)
App.tsx reducer + persistence + color toolbar + status
index.css global styles
main.tsx Vite entry
pnpm test runs 11 tests (Vitest + React Testing Library + jsdom): 8 reducer
unit tests and 3 StickyNote component tests, including one that simulates a
full drag (mouseDown → mouseMove on window → mouseUp) and asserts the
exact committed position, which is what proves the drag math.
- No undo/redo.
- Fixed-size canvas (no panning/zoom),
min-width: 1024pxper the brief. - The mock API's
SIMULATED_FAILURE_RATEdefaults to0; set it above zero locally to exercise the load/save error states already wired into the toolbar. - Trash detection recomputes
getBoundingClientRect()on each drag frame — cheap at this scale, the kind of thing to revisit only at hundreds of notes.
