The correctness finding is a real bug and the interesting one: arming a plant
from the Seed Tray never attributed a seed lot, not even in the unambiguous
single-lot case. The tray is the QUICKEST way to place a plant, so the fastest
path was the one that silently lost which packet the seed came from — and it
would have looked fine, because attribution is invisible until you go looking at
a remaining count that's wrong. armPlant now does the same single-lot
auto-attribution the picker does. Several lots stays unattributed rather than
guessing; the picker is where that choice belongs.
A single exhausted lot was also indistinguishable from having no lot, because
the "available" filter dropped it before the count was taken. That silently
dropped attribution with no signal. attributableLots now offers exhausted lots
too, just not first: the count records what was written down, not a fact about
the packet, so refusing to attribute a planting because the number says zero
makes a wrong number harder to correct rather than easier. It's also now one
rule in one place instead of the same filter written twice.
The seed lot form had no 409 handling, alone among the version-guarded forms
here. A concurrent edit made you retype everything; it now rebases onto the
current row and says so, like the garden and plant forms do.
Deduplication: the outbound-link anchor was duplicated verbatim between the
plant card and the lot rows, including the security-relevant rel attribute — now
one SourceLink that re-checks the URL and renders nothing when it isn't safe, so
callers can't forget to guard. remainingLabel had its own copy of the
unit-agreement rule that summarizeLots already owns; it now calls it.
formatQuantity moved out of a component file into lib/seedLots.ts with the other
formatters.
STATE_CLASS.over and .low were identical. "Over-planted" is a discrepancy to
look at, not a shortage to act on, so it now reads as its own warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
question gets asked twice — at the seed rack in January, and standing over a bed
in May — so the answer shows up in both places.
On the plant card: vendor and a source link, plus a seed count that sits with
the actions rather than in the body, because it's what you scan down a list of
twenty packets deciding what to order. Expanding shows each lot separately with
vendor, dates, germination, cost, and its own remaining count — two lots of one
variety are two rows with independent numbers, which is the whole reason
inventory lives on the purchase rather than on the plant.
State is encoded in form as well as number: a coloured chip that says "low" or
"empty" in words, and a proportional bar. A bare number doesn't survive being
skimmed, and skimming is the actual use. "Over-planted" is a state of its own
rather than being clamped to zero — planting more than you recorded buying
happens, and hiding it defeats the point of tracking.
In the plant picker, remaining shows next to the plant you're about to place,
and a plant with several lots asks which packet it came out of. One lot
auto-attributes and zero lots stays silent: forcing that question on every
placement is how a nicety becomes an obstacle. The armed lot rides along with
the armed plant, so repeat placement keeps attributing without asking again.
External URLs are re-checked client-side before being rendered as links, and
carry rel="noopener noreferrer". The server already scheme-checks them, but a
link is rendered from whatever the client was handed — an old row, a different
server version, a tampered response — and "the backend validated it" is not a
reason to hand javascript: to an anchor tag.
Retiring a lot says plainly that the plantings survive it, since "will this wipe
my garden" is the reasonable fear.
Closes#51
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Closes#44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action.
Closes#41.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Use the full Plant the picker returns (fixes the silent placement failure) and add a per-garden Seed Tray for quick repeat placing. Review fixes: disarm on tray-remove, cap load, consolidate toolbar guards, rename interface, tidy.
Closes#39.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Fixes from the PR #29 adversarial review (considered; not graded).
Performance / correctness
- ObjectShape is memo'd, so a pan/zoom (which only mutates the world <g>
transform) no longer re-renders every object.
- 'circle' objects render as an <ellipse> (rx/ry), honoring both dims — a
circle when width == height — instead of ignoring heightCm.
- The 1 m grid now actually fades in (opacity ramps as cells grow past the
visibility threshold), matching its description.
- Unique SVG pattern id (useId) so multiple canvases can't collide.
- The canvas re-fits when the garden id changes (not just once), so #11
switching gardens reframes.
Robustness
- geometry.zoomToFitRect takes rect size by magnitude; wheel/pinch ignore
non-finite deltas; ObjectShape clamps dimensions to >= 0 (no invalid SVG).
Maintainability
- Renamed the Size params from `viewport` to `canvasSize` (4 models); moved
easeInOutCubic/lerp into geometry.ts; renamed toLocal → clientToCanvas;
named constants for the label/corner factors; DEFAULT_FILL const;
EditorGarden.unitPref imports UnitPref; the Fit control uses the shared
Button. focusedObjectId/plantable are intentionally reserved for #11/#15.
tsc + 17 vitest tests green; re-verified in a browser (ellipses render,
unique pattern id, wheel zoom + Fit).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
The editor's technically-riskiest slice, built against mock data so #11
only adds interactions on top.
- lib/geometry.ts (+ vitest): world<->screen and object-local<->world
transforms, clampScale, zoomViewportAt (the wheel/pinch "keep the point
under the cursor stationary" math), zoomToFitRect. 11 geometry tests
(round-trips, rotation, cursor-anchored zoom, fit) + 6 units tests.
- editor/store.ts: Zustand store for ephemeral editor state — viewport,
selection, focusedObjectId (selection interactions grow in #11).
- editor/useViewport.ts: @use-gesture wiring — wheel + pinch zoom toward
the pointer, drag-pan, scale clamped to [0.05, 20] px/cm, and an animated
fitToRect (eased rAF tween). touch-action:none so the browser doesn't
fight gestures.
- editor/ObjectShape.tsx: rect/circle centered + rotated, kind default
colors, name label; non-scaling strokes so outlines stay 1px at any zoom.
- editor/GardenCanvas.tsx: full-size svg, single world <g>, fading 1m grid,
garden boundary, z-ordered objects; ResizeObserver-driven auto-fit, a
zoom readout, and a Fit control. Deps: zustand, @use-gesture/react,
vitest (+ test scripts).
- GardenEditorPage renders the canvas with a mock scene (#11 swaps in
/full).
Verified in a real browser: wheel zoom keeps the world point under the
cursor stationary (sub-cm drift on a 12m garden), a real drag pans, and
Fit animates to frame the garden. tsc + vitest green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Fixes from the PR #27 adversarial review (considered; not graded).
Correctness / error-handling
- Modal: focus once on mount and attach the keydown listener once (latest
onClose/busy via refs), so a parent re-render (e.g. a background refetch)
no longer re-fires focus() and steals it from the input being typed in.
- Modal takes a `busy` prop; while a save/delete is in flight, Escape and
backdrop clicks don't dismiss it (form/delete modals pass busy=pending),
so an action can't be interrupted mid-flight and lose its error.
- Form validates the *converted* centimeter values against the server's
[1cm, 100m] bounds, so sub-cm / over-100m sizes fail client-side with a
clear message instead of a generic server error.
- displayFromCm rounds imperial to 0.1 ft so an 8 ft garden (244 cm)
prefills as "8", not "8.01".
Maintainability
- Extracted ui/field.ts (useFieldId + fieldControlClass) shared by
TextField/TextArea/Select. Added a `danger` Button variant, used by the
delete modal; card edit/delete buttons gained focus-visible rings.
- useUpdateGarden takes the id in its mutation variables (no dummy 0 during
create). GardensPage shares a close handler; dropped a redundant zod
annotation; 409 rebase reuses dimString; renamed `del` -> `deletion`.
Verified in a browser: 8 ft round-trips to a prefilled "8"; a sub-cm value
is rejected client-side with the modal staying open. tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
The /gardens page is now home base, backed by the #7 API.
- lib/units.ts: cm <-> meters/feet conversion + formatCm/formatDimensions
(metric "m", imperial "ft/in"). Minimal for #8; #9's geometry lib
consolidates.
- lib/gardens.ts: zod gardenSchema + useGardens/useCreateGarden/
useUpdateGarden/useDeleteGarden (mutations invalidate the list) and
conflictGarden() to pull the fresh row out of a 409.
- GardensPage: loading/empty/error states; empty state prompts a first
garden; responsive card grid (single column on phones).
- GardenCard: name, dimensions formatted per the garden's unit, notes
preview; body links into /gardens/:id, edit/delete kept out of the link.
- GardenFormModal: create/edit with a unit selector; dimensions are entered
in the chosen unit and converted to cm (switching units converts the
current values). A 409 rebases the form onto the server's fresh row.
- DeleteGardenModal: confirmation before removing.
- ui/: Modal (Escape/backdrop close), TextArea, Select.
Verified in a real browser against the embedded binary: create appears
without reload; edit persists (version 2), form prefilled from stored cm
converted back to the garden's unit; delete confirms then removes -> empty
state; an imperial 4 ft x 8 ft garden stores 122 x 244 cm and shows
"4' 0" x 8' 0"". tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Fixes from the PR #25 adversarial review (graded 23 real / 5 false positive):
Error handling
- onLogout wraps mutateAsync in try/catch: a failed logout no longer
becomes an unhandled rejection; the user stays put (session still valid)
and the button offers "Retry sign out" (5 models flagged this).
- Root errorComponent (RouteError): a non-401 /auth/me failure in a
beforeLoad guard now shows a recoverable "Try again" screen instead of
blanking.
- Forms drop noValidate, restoring native required/type=email/minLength
checks before hitting the server.
Correctness
- RegisterPage gates on providers.isPending/isError before rendering, so
the form no longer briefly appears (submittable) on an SSO-only server.
- TextField id falls back to name then a useId() value, so the label/hint
associations hold even if a caller omits both.
Maintainability
- Single safeRedirectPath (lib/redirect.ts) replaces the duplicated
safeRedirect/safeInternalPath (4 models).
- Generic ApiError helpers (apiErrorCode, errorMessage) moved to lib/api.ts;
LoginPage builds the OIDC URL from the exported API_BASE.
- Divider moved to components/ui/. errorMessage doc clarified.
Verified again in a real browser: register -> /gardens; Sign out -> /login.
tsc --noEmit and vite build clean.
Not changed (graded, with rationale): OIDC deep-link redirect isn't
preserved (needs backend state threading — follow-up); 60s me staleTime in
guards (server is authoritative); the login/register onSubmit catches are
the intended react-query pattern (errors render via mutation state).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Frontend for pansy auth, rendering whatever /auth/providers reports.
- lib/auth.ts: zod-validated User/Providers, a shared meQueryOptions
(a 401 resolves to null, not an error) reused by useMe() and the router
guard, useProviders(), and useLogin/useRegister/useLogout mutations that
prime/clear the me cache (logout also drops non-auth cached data).
- lib/queryClient.ts: one QueryClient shared by the React tree and the
router context so guard and components hit the same cache.
- router.tsx: createRootRouteWithContext with the queryClient; requireAuth
(redirects to /login?redirect=<dest>) on gardens/plants/editor, and
requireGuest on login/register (bounces authed users away). Login search
params (redirect, error) validated as optional; redirect targets are
restricted to same-origin paths.
- pages/LoginPage: OIDC button (label from providers) linking to
/auth/oidc/login, "or" divider, local email/password form, and friendly
messages for the OIDC callback ?error= codes. RegisterPage: email +
display name + password (min 8), registration-closed and SSO-only
handling; auto-login lands on /gardens.
- AppShell: auth-aware nav — shows the user's display name + Sign out when
logged in, Sign in otherwise; nav links only when authed.
- ui/: TextField (16px text to avoid iOS zoom, autocomplete + a11y),
Button (+ shared buttonClasses for the OIDC anchor), Alert; AuthCard.
Verified in a real browser against the embedded binary: register →
auto-login → /gardens; refresh stays in; Sign out → /login; a logged-out
/gardens/1 redirects to /login and returns after login; OIDC button renders
with the Authentik label when configured. tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Applied the fixes warranted by the adversarial review of PR #21 (all
findings graded in the gadfly store):
Store (highest impact):
- buildDSN always merges busy_timeout/journal_mode/foreign_keys pragmas
into the DSN, even for file: URIs or paths with a query string — the
prior passthrough silently disabled FK enforcement for those PANSY_DB
values. Also escape '#' in the path and tighten in-memory detection to
an exact ':memory:' token or mode=memory param (no substring misfire).
- migrate.go: detect duplicate migration versions with a clear error;
wrap appliedVersions' rows.Err() with the store: prefix.
API:
- SetTrustedProxies failure now falls back to trust-none instead of
leaving gin's trust-everyone default (X-Forwarded-For spoofing).
- SPA: 404 embedded directories (was a directory listing), 404 bare /api,
add X-Content-Type-Options: nosniff, cache index.html bytes once at
startup, single fs.Stat existence check, shared writeAPIError helper.
- Move gin.SetMode out of package init() into New().
Config: validate PANSY_PORT range (fall back to 8080 with a warning).
Web:
- api.ts: serialize the request body before the fetch try so a
JSON.stringify failure isn't misreported as a network error.
- AppShell: move state-specific/conflicting utilities into
active/inactiveProps so concatenated Tailwind classes don't collide.
Tests: added store DSN-pragma/memory-detection cases and SPA
directory-404 case. go build/vet/test clean (CGO off); web tsc + build
clean; re-verified in a browser (SPA, deep links, /assets 404, nav).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Implements the Pansy v1 scaffold (epic #20, phase 0).
#1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config,
gin server with slog + recovery + /api/v1/healthz, modernc SQLite store
with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration
runner, full initial schema (users, sessions, gardens, garden_shares,
garden_objects, plants, plantings), domain structs + sentinel errors,
graceful shutdown.
#2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack
Router/Query, typed /api/v1 fetch wrapper (ApiError carries status +
body so later issues can read 409 conflict rows), dev proxy /api -> :8080,
responsive app shell with stub pages for all five routes.
#3 Single binary: //go:embed of the web build with a committed placeholder,
SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched
/api and missing assets), Makefile (web/build/dev/test), README quickstart
+ env var table.
Verified: go build/vet/test clean (CGO off); binary migrates idempotently and
serves healthz; web tsc + build clean; integrated binary serves the SPA, deep
links, and correct cache headers; app mounts and navigates all five routes at
desktop and 375px widths.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi