8 Commits
Author SHA1 Message Date
steve f0aefb5378 Merge pull request 'Make request deadline extensions reach the socket behind the logging middleware' (#126) from fix/sse-deadlines-behind-middleware into main
Build image / build-and-push (push) Successful in 9s
2026-08-23 03:10:37 +00:00
steveandClaude Fable 5 68cb686d60 Address #126 review: one home for the middleware rationale
Build image / build-and-push (push) Successful in 20s
The why-a-controller-can't-reach-the-socket story was told in full in
deadlines.go, agent.go, the test, and CLAUDE.md. It lives in deadlines.go
now; the others say what they need to and point there.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:09:36 -04:00
steveandClaude Fable 5 2a903f6428 Make request deadline extensions reach the socket behind the logging middleware
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m9s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m10s
Long agent turns were cut at exactly 30s on the live instance with "The
connection dropped partway through." — the #78 failure, which its tests
said was fixed. The tests host openEventStream on a bare gin.New(); in
production, slog-gin replaces c.Writer with a wrapper that embeds the
gin.ResponseWriter interface, which has no Unwrap, so the ResponseController
built from the handler's writer can't reach the connection and every
SetWriteDeadline returns ErrNotSupported. The stream fell back to the
server's absolute WriteTimeout; the first write past it failed, cancelled
the request context, and closed the socket under the client mid-frame.
The scan upload's read/write extensions failed the same way, with the
errors discarded.

captureController now runs first on the engine and stashes a controller
built before anything wraps the writer; openEventStream and scanSeedPacket
take it from responseController(c). The regression tests run the stream
through New() — the real stack, in the real order — and check from the
client side; the scan path logs once instead of swallowing the error.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:56:21 -04:00
steve 5622b1accd Merge pull request 'Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers' (#125) from fix/smoke-sweep into main
Build image / build-and-push (push) Successful in 7s
2026-08-23 02:26:06 +00:00
steveandClaude Fable 5 0d95578c6a Address #125 review: memoized ink, one fallback color, reactive copy name
Build image / build-and-push (push) Successful in 10s
- monogramInk is memoized by color string; the canvas asks for every
  visible plop on every frame of a pan (Gadfly, 2/4 models).
- FALLBACK_PLANT_COLOR lives in lib/plants and is used by the canvas, the
  inspector and the garden thumbnail instead of three raw '#97a97c's.
- CopyDialog keeps its proposed "<base> — <year>" in step with the gardens
  list until the person edits the name, so a list that loads after the
  dialog opens can't leave a taken year in the field.
- GardenCard: reflowed the summary comment; no dead fallback on a plan
  name that's already known to parse.
- today() has one import path (lib/dates); the journal re-export is gone.
- CLAUDE.md says what the inspector actually does (a text-compare guard)
  rather than claiming it uses LengthField.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:25:02 -04:00
steveandClaude Fable 5 157e04ed24 Skip no-op saves in the edit dialogs; clear a stale model-spec error
Build image / build-and-push (push) Successful in 26s
A Save that changed nothing still sent a PATCH, which bumped the row's
version and landed an "Edited garden settings" step in History that undid
nothing — the drift is gone since the last commit, but the write was still
there. Both dialogs now close without a request when every field matches
the loaded row.

In Settings, a rejected model spec's reason stayed under the field after
the field was blanked back to the saved value; committing an unchanged
value now clears it.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:22:22 -04:00
steveandClaude Fable 5 27f658c1f7 Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
Build image / build-and-push (push) Successful in 2m55s
Gadfly review (reusable) / review (pull_request) Successful in 8m59s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m59s
- Garden and plant dialogs keep centimeters as the source of truth
  (LengthField in lib/units.ts): a no-change Save no longer rewrites
  900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and
  writing bogus history entries on the way.
- The UI stamps every date with the browser's local day (lib/dates.ts).
  Journal notes already did; plop placement, fill and removal now do too,
  so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an
  optional plantedAt; API and agent callers still default to UTC today.
- Removing an object that holds plants asks first and says how many go
  with it. An empty one still goes straight away (one Undo restores it).
- The expanded plant card's action row wraps instead of clipping "Delete".
- Monogram lettering switches to a dark ink on pale marker colors (garlic,
  cabbage, marigold) instead of near-white on near-white.
- Copy-as-plan proposes the next free year and warns when the typed name
  already exists, so two gardens can't both read as "the 2027 plan".
- Plan cards show the base name with a "2027 plan" tag, so the year — the
  point of the name — survives truncation.
- A rejected model spec now says which model and why: a wrapped
  ErrInvalidInput's reason reaches the client as the 400's message, and the
  Settings field shows it inline instead of toasting "invalid input".

Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved,
which only passed while the real date was before 2026-08-01.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:11:12 -04:00
steve 05392ee0db Merge pull request 'Replace the UI with the Organic design handoff' (#124) from feat/organic-ui into main
Build image / build-and-push (push) Successful in 6s
2026-08-22 23:40:23 +00:00
44 changed files with 704 additions and 149 deletions
+27
View File
@@ -129,6 +129,24 @@ Conventions that follow from it:
a constraint between neighbouring plants; a bed edge is nobody's neighbour. a constraint between neighbouring plants; a bed edge is nobody's neighbour.
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads - **Soft removal**: "clear bed" sets `removed_at`; the editor reads
`removed_at IS NULL`. Hard delete is a different operation. `removed_at IS NULL`. Hard delete is a different operation.
- **Length fields keep centimeters as the source of truth.** A dialog field
that takes a length is a `LengthField` (`web/src/lib/units.ts`): the text is
a view, `cm` changes only when the person types. Never re-parse the display
string on save — "29 6.3″" is the nearest tenth of an inch, and parsing it
back is how a no-change Save turned 900 cm into 899.922 (and bumped the
version, and wrote a bogus history entry). The inspector still keeps display
strings but gets the same result by refusing to commit text that still equals
the formatted original (`commitDim`); either way, a no-op save sends exactly
what was loaded — or nothing.
- **"Today" is the browser's local day**, from `today()` in
`web/src/lib/dates.ts`, and the UI always sends it: journal `observedAt`,
plop/fill `plantedAt`, `removedAt`. The server's UTC default is only for
API callers and the agent. A gardener placing at 9 pm in Ohio planted today,
not tomorrow — don't add a UI path that leaves the date to the server.
- **A wrapped `ErrInvalidInput` is shown to the person verbatim.**
`fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)`
reaches the client as the 400's message (minus the sentinel prefix); the bare
sentinel reads "invalid input". Write the reason for the keyboard, not the log.
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run - **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
at startup, embedded. Never edit one that has shipped. at startup, embedded. Never edit one that has shipped.
- **Every service mutation lands in history** (#48). If you add one, record it — - **Every service mutation lands in history** (#48). If you add one, record it —
@@ -142,6 +160,15 @@ Conventions that follow from it:
plantings. Fixing it per-call-site is how it came back, which is why the rule plantings. Fixing it per-call-site is how it came back, which is why the rule
lives in `commitScope` where no caller can forget it. lives in `commitScope` where no caller can forget it.
- **Request deadlines are extended through `responseController(c)`, never
`http.NewResponseController(c.Writer)`.** A controller built in a handler
can't reach the socket — the logging middleware wraps the writer — so every
deadline call silently returns `ErrNotSupported`, in production only;
`internal/api/deadlines.go` has the mechanism and why `captureController`
must stay the first middleware. Corollary for tests: a deadline test must run
through `New()`, not `gin.New()` — the #78 fix shipped fully tested on a bare
engine and never worked on the live instance.
## Testing ## Testing
Match the test to the failure it would catch: Match the test to the failure it would catch:
+1 -1
View File
@@ -64,7 +64,7 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link) POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
POST /gardens/:id/objects PATCH,DELETE /objects/:id POST /gardens/:id/objects PATCH,DELETE /objects/:id
POST /objects/:id/plantings PATCH,DELETE /plantings/:id POST /objects/:id/plantings PATCH,DELETE /plantings/:id
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect; optional plantedAt (default UTC today)
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
GET,POST /plants PATCH,DELETE /plants/:id (own plants only) GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private) GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
+1 -1
View File
@@ -60,7 +60,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bed: %v", err) t.Fatalf("bed: %v", err)
} }
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil { if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
t.Fatalf("seed garlic: %v", err) t.Fatalf("seed garlic: %v", err)
} }
+3 -1
View File
@@ -163,7 +163,9 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"` SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"` Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
}) (any, error) { }) (any, error) {
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode)) // nil: the agent runs server-side with no local day, so the fill dates
// plops UTC-today like its create_planting does.
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode), nil)
} }
func (a *adapter) findPlant(ctx context.Context, args struct { func (a *adapter) findPlant(ctx context.Context, args struct {
+1 -1
View File
@@ -169,7 +169,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bed: %v", err) t.Fatalf("bed: %v", err)
} }
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil { if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
t.Fatalf("seed the garlic: %v", err) t.Fatalf("seed the garlic: %v", err)
} }
+4 -1
View File
@@ -164,11 +164,14 @@ type eventStream struct {
// write path. Only the client sees it, as a truncated stream it reports as a // write path. Only the client sees it, as a truncated stream it reports as a
// dropped connection. Hence a deadline set up front and refreshed per frame, // dropped connection. Hence a deadline set up front and refreshed per frame,
// rather than anything checked after the fact. // rather than anything checked after the fact.
//
// The controller comes from responseController, not from c.Writer — a
// controller built here can't reach the socket; deadlines.go says why.
func openEventStream(c *gin.Context) *eventStream { func openEventStream(c *gin.Context) *eventStream {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
s := &eventStream{c: c, rc: http.NewResponseController(c.Writer)} s := &eventStream{c: c, rc: responseController(c)}
// Probe once here rather than reporting per frame: a writer that can't take // Probe once here rather than reporting per frame: a writer that can't take
// deadlines will fail identically on every write, and the operator needs to // deadlines will fail identically on every write, and the operator needs to
// hear it once. If this fails the stream still works — it is just back to // hear it once. If this fails the stream still works — it is just back to
+5 -1
View File
@@ -39,7 +39,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
r.Use(sloggin.New(slog.Default()), gin.Recovery()) // captureController goes first, on purpose: the logging middleware wraps
// c.Writer in a type a ResponseController can't see through, and anything
// that extends a request deadline (the SSE chat stream, the scan upload)
// needs a controller built before that happens. See deadlines.go.
r.Use(captureController(), sloggin.New(slog.Default()), gin.Recovery())
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil { if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
// Do not leave gin's trust-everyone default active on a parse failure — // Do not leave gin's trust-everyone default active on a parse failure —
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// responseControllerKey is where captureController stashes the controller in
// the gin context for responseController to find.
const responseControllerKey = "pansy.responseController"
// captureController hands every handler an http.ResponseController that can
// actually reach the connection. It MUST be the first middleware on the engine.
//
// A ResponseController finds the connection's deadline setters by unwrapping
// the ResponseWriter it was built from, one layer at a time, until it reaches
// one that has them. gin's own writer unwraps cleanly. The logging middleware's
// does not: it replaces c.Writer with a type that embeds the gin.ResponseWriter
// INTERFACE, which has no Unwrap, so a controller built from c.Writer inside a
// handler stops there and every SetReadDeadline/SetWriteDeadline returns
// ErrNotSupported. That left the per-frame SSE deadline (#78) and the scan
// upload's extensions dead in production while their tests — on a bare engine
// with no logging — passed: long agent turns were cut at the server's absolute
// 30s WriteTimeout, and the client saw "The connection dropped partway through."
//
// Building the controller here, ahead of every wrapper, sidesteps the question
// of what any later middleware does to the writer. Handlers that extend a
// deadline take it from responseController; sse_deadline_test.go runs the
// scenario through New so a reorder or a new wrapper fails a test.
func captureController() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set(responseControllerKey, http.NewResponseController(c.Writer))
c.Next()
}
}
// responseController returns the controller captureController stored, or — on
// an engine without that middleware, which only tests build — one made from
// c.Writer as it stands.
func responseController(c *gin.Context) *http.ResponseController {
if v, ok := c.Get(responseControllerKey); ok {
if rc, ok := v.(*http.ResponseController); ok {
return rc
}
}
return http.NewResponseController(c.Writer)
}
+17 -1
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -52,7 +53,7 @@ func writeServiceError(c *gin.Context, err error) {
case errors.Is(err, domain.ErrOIDCIdentityConflict): case errors.Is(err, domain.ErrOIDCIdentityConflict):
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account") writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
case errors.Is(err, domain.ErrInvalidInput): case errors.Is(err, domain.ErrInvalidInput):
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input") writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err))
default: default:
slog.Error("api: unhandled service error", "error", err) slog.Error("api: unhandled service error", "error", err)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error") writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
@@ -114,3 +115,18 @@ func parseIDParam(c *gin.Context, name string) (int64, bool) {
} }
return id, true return id, true
} }
// inputMessage is the text a 400 carries for an ErrInvalidInput. The bare
// sentinel reads "invalid input"; a service that wraps it with a reason —
// fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)
// — has that reason shown to the person verbatim, minus the sentinel prefix.
// So anything wrapped this way is written for the keyboard, not the log (see
// the note on domain.ErrInvalidInput).
func inputMessage(err error) string {
msg := err.Error()
base := domain.ErrInvalidInput.Error()
if msg == base {
return msg
}
return strings.TrimPrefix(msg, base+": ")
}
+30
View File
@@ -0,0 +1,30 @@
package api
import (
"errors"
"fmt"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// TestInputMessage: the bare sentinel stays generic; a wrapped reason reaches
// the person without the "invalid input: " prefix in front of it.
func TestInputMessage(t *testing.T) {
cases := []struct {
err error
want string
}{
{domain.ErrInvalidInput, "invalid input"},
{fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, "nonesuch/model"), `chat model "nonesuch/model": unknown provider`},
{fmt.Errorf("loading: %w", domain.ErrInvalidInput), "loading: invalid input"},
}
for _, c := range cases {
if !errors.Is(c.err, domain.ErrInvalidInput) {
t.Fatalf("%v should still be an ErrInvalidInput", c.err)
}
if got := inputMessage(c.err); got != c.want {
t.Errorf("inputMessage(%v) = %q, want %q", c.err, got, c.want)
}
}
}
+6 -2
View File
@@ -55,6 +55,10 @@ type objectFillRequest struct {
// (individual plants in rows at true spacing). Empty = clump. An unknown value // (individual plants in rows at true spacing). Empty = clump. An unknown value
// is refused by the service (#77). // is refused by the service (#77).
Layout string `json:"layout"` Layout string `json:"layout"`
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). The UI sends its
// local day; omitted, the server uses UTC today — which is tomorrow for an
// evening gardener west of Greenwich, so clients that know better say so.
PlantedAt *string `json:"plantedAt"`
} }
func (h *handlers) fillObject(c *gin.Context) { func (h *handlers) fillObject(c *gin.Context) {
@@ -88,9 +92,9 @@ func (h *handlers) fillObject(c *gin.Context) {
return return
} }
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY} region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout)) created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
} else { } else {
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout)) created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
} }
if err != nil { if err != nil {
writeServiceError(c, err) writeServiceError(c, err)
+9 -3
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"errors" "errors"
"log/slog"
"net/http" "net/http"
"time" "time"
@@ -40,9 +41,14 @@ const scanWriteTimeout = 120 * time.Second
func (h *handlers) scanSeedPacket(c *gin.Context) { func (h *handlers) scanSeedPacket(c *gin.Context) {
// Extend both deadlines for the (potentially large, potentially slow) upload // Extend both deadlines for the (potentially large, potentially slow) upload
// and the live vision call that follows. Best-effort: if the writer doesn't // and the live vision call that follows. Best-effort: if the writer doesn't
// support it, the server defaults apply. // support it, the server defaults apply — but say so, once, because this
rc := http.NewResponseController(c.Writer) // failed silently behind the logging middleware for as long as the errors
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout)) // were discarded (see deadlines.go). The second call can only fail the same
// way as the first, so it isn't reported twice.
rc := responseController(c)
if err := rc.SetReadDeadline(time.Now().Add(scanReadTimeout)); err != nil {
slog.Error("api: scan deadlines unavailable; slow uploads will be cut at the server ReadTimeout", "error", err)
}
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout)) _ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
+16 -2
View File
@@ -1,7 +1,9 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -159,10 +161,22 @@ func TestSettingsRejectsBadModel(t *testing.T) {
admin := registerAndCookie(t, r, "[email protected]") admin := registerAndCookie(t, r, "[email protected]")
v := settingsVersion(t, r, admin) v := settingsVersion(t, r, admin)
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest { map[string]any{"agentModel": "nonesuch/model", "version": v}, admin)
if w.Code != http.StatusBadRequest {
t.Errorf("bad model: status %d, want 400", w.Code) t.Errorf("bad model: status %d, want 400", w.Code)
} }
// The message says which field and which spec, so the page can show a reason
// rather than a bare "invalid input".
var body struct {
Error struct{ Code, Message string } `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode bad-model body: %v", err)
}
if body.Error.Code != "INVALID_INPUT" || !strings.Contains(body.Error.Message, "chat model") || !strings.Contains(body.Error.Message, "nonesuch/model") {
t.Errorf("bad model error = %+v, want INVALID_INPUT naming the chat model and spec", body.Error)
}
// agentEnabled must be a bool or null, not a string. // agentEnabled must be a bool or null, not a string.
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest { map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
+67 -10
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"bufio" "bufio"
"net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
@@ -10,15 +11,22 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// streamFrames spins up a real http.Server with the given WriteTimeout and an // bareEngine is a gin engine with NO middleware: the narrowest possible host for
// SSE handler that emits `frames` data frames, one every `tick`, then returns. // openEventStream, and what the #78/#87 tests were originally written against.
// It reports how many frames the client actually received and any read error — // It is not what production runs — the middleware stack in New wraps the
// the only vantage point from which the deadline failures in #78/#87 are // ResponseWriter, and that difference is the whole subject of the third test.
// visible, since the writes themselves return nil when the bytes are dropped. func bareEngine() *gin.Engine {
func streamFrames(t *testing.T, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
r := gin.New() return gin.New()
}
// streamFrames spins up a real http.Server around r with the given WriteTimeout
// and an SSE route that emits `frames` data frames, one every `tick`, then
// returns. It reports how many frames the client actually received and any read
// error — the only vantage point from which the deadline failures in #78/#87 are
// visible, since the writes themselves return nil when the bytes are dropped.
func streamFrames(t *testing.T, r *gin.Engine, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
t.Helper()
r.GET("/stream", func(c *gin.Context) { r.GET("/stream", func(c *gin.Context) {
s := openEventStream(c) s := openEventStream(c)
for i := 0; i < frames; i++ { for i := 0; i < frames; i++ {
@@ -64,7 +72,7 @@ func TestEventStreamOutlivesServerWriteTimeout(t *testing.T) {
// keeps the stream alive with a huge margin — CI slowness only ever makes // keeps the stream alive with a huge margin — CI slowness only ever makes
// this pass more surely. The server's 300ms WriteTimeout is the thing being // this pass more surely. The server's 300ms WriteTimeout is the thing being
// overridden; frames straddle it (300ms/600ms/900ms). // overridden; frames straddle it (300ms/600ms/900ms).
got, err := streamFrames(t, 300*time.Millisecond, 300*time.Millisecond, 3) got, err := streamFrames(t, bareEngine(), 300*time.Millisecond, 300*time.Millisecond, 3)
if err != nil { if err != nil {
t.Errorf("client read error after %d/3 frames: %v", got, err) t.Errorf("client read error after %d/3 frames: %v", got, err)
} }
@@ -90,7 +98,7 @@ func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
// The server WriteTimeout is generous (5s), so it isn't the limiter — the // The server WriteTimeout is generous (5s), so it isn't the limiter — the
// per-frame sseWriteTimeout is. 8 frames at a 100ms tick span 800ms, well past // per-frame sseWriteTimeout is. 8 frames at a 100ms tick span 800ms, well past
// the 400ms deadline, but each 100ms gap is a 4× margin under it. // the 400ms deadline, but each 100ms gap is a 4× margin under it.
got, err := streamFrames(t, 5*time.Second, 100*time.Millisecond, 8) got, err := streamFrames(t, bareEngine(), 5*time.Second, 100*time.Millisecond, 8)
if err != nil { if err != nil {
t.Errorf("client read error after %d/8 frames: %v", got, err) t.Errorf("client read error after %d/8 frames: %v", got, err)
} }
@@ -99,3 +107,52 @@ func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
got, sseWriteTimeout) got, sseWriteTimeout)
} }
} }
// TestEventStreamOutlivesWriteTimeoutBehindMiddleware is #78 again, through the
// production middleware stack — which is where it was still broken.
//
// The two tests above passed while the deployed instance cut every agent turn
// at exactly 30s: they host openEventStream on a bare engine, and it is the
// logging middleware in New that hides the socket from a ResponseController
// built in a handler (deadlines.go has the mechanism). So: the same scenario as
// the first test, hosted on the engine New builds, in the order cmd/pansy runs
// it. Any future middleware that wraps the writer, or a reorder that puts one
// ahead of the controller capture, fails here.
func TestEventStreamOutlivesWriteTimeoutBehindMiddleware(t *testing.T) {
got, err := streamFrames(t, authEngine(t, localCfg()), 300*time.Millisecond, 300*time.Millisecond, 3)
if err != nil {
t.Errorf("client read error after %d/3 frames: %v", got, err)
}
if got != 3 {
t.Errorf("client received %d frames, want 3 — the stream was cut at the server WriteTimeout; the deadline override is not reaching the socket through the middleware stack", got)
}
}
// TestResponseControllerReachesTheSocketBehindMiddleware pins the mechanism the
// test above depends on, for every handler that extends a deadline — the scan
// upload extends both (seed_packet.go), and its calls were failing just as
// silently, with the errors discarded.
func TestResponseControllerReachesTheSocketBehindMiddleware(t *testing.T) {
r := authEngine(t, localCfg())
var readErr, writeErr error
r.GET("/deadlines", func(c *gin.Context) {
rc := responseController(c)
readErr = rc.SetReadDeadline(time.Now().Add(time.Minute))
writeErr = rc.SetWriteDeadline(time.Now().Add(time.Minute))
c.Status(http.StatusNoContent)
})
srv := httptest.NewServer(r)
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/deadlines")
if err != nil {
t.Fatalf("get: %v", err)
}
resp.Body.Close()
if readErr != nil {
t.Errorf("SetReadDeadline through the production middleware: %v", readErr)
}
if writeErr != nil {
t.Errorf("SetWriteDeadline through the production middleware: %v", writeErr)
}
}
+4 -1
View File
@@ -33,7 +33,10 @@ var (
ErrShareExists = errors.New("garden already shared with that user") ErrShareExists = errors.New("garden already shared with that user")
// ErrInvalidInput means the caller supplied structurally invalid data (empty // ErrInvalidInput means the caller supplied structurally invalid data (empty
// required field, malformed value). Mapped to 400. // required field, malformed value). Mapped to 400. Wrap it with the reason —
// fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", ErrInvalidInput) —
// and the API shows that reason to the person verbatim, so write it for
// them, not for a log; the bare sentinel reads as just "invalid input".
ErrInvalidInput = errors.New("invalid input") ErrInvalidInput = errors.New("invalid input")
// ErrInvalidCredentials means a login attempt failed. It is deliberately // ErrInvalidCredentials means a login attempt failed. It is deliberately
// identical for an unknown email and a wrong password so neither can be // identical for an unknown email and a wrong password so neither can be
+19 -5
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"errors"
"fmt"
"strings" "strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel" "gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
@@ -61,12 +63,15 @@ func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, pat
model := strings.TrimSpace(patch.AgentModel) model := strings.TrimSpace(patch.AgentModel)
vision := strings.TrimSpace(patch.VisionModel) vision := strings.TrimSpace(patch.VisionModel)
// Validate non-empty specs up front. An empty one is the "inherit env" // Validate non-empty specs up front. An empty one is the "inherit env"
// sentinel and needs no check — the env value was validated at boot. // sentinel and needs no check — the env value was validated at boot. The
for _, spec := range []string{model, vision} { // reason rides on the sentinel so the 400 can show it: "unknown provider"
if spec != "" { // is something a person can act on, "invalid input" is not.
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil { for _, f := range []struct{ label, spec string }{{"chat model", model}, {"vision model", vision}} {
return nil, domain.ErrInvalidInput if f.spec == "" {
continue
} }
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, f.spec); err != nil {
return nil, fmt.Errorf("%w: %s %q: %v", domain.ErrInvalidInput, f.label, f.spec, specReason(err))
} }
} }
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{ return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
@@ -166,3 +171,12 @@ func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, Effectiv
} }
return s.agentOver(st), s.visionOver(st), nil return s.agentOver(st), s.visionOver(st), nil
} }
// specReason strips agentmodel's own "resolve %q:" wrapping so the message
// reads "unknown provider …" rather than repeating the spec twice.
func specReason(err error) string {
if u := errors.Unwrap(err); u != nil {
return u.Error()
}
return err.Error()
}
+16 -8
View File
@@ -175,13 +175,15 @@ func validFillLayout(l FillLayout) (FillLayout, bool) {
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half- // in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its // spacing for a clump (see edgeInset for the why). A candidate is skipped when its
// plop would sit entirely inside an existing active plop (so re-filling doesn't // plop would sit entirely inside an existing active plop (so re-filling doesn't
// stack duplicates). Returns the plops it created. // stack duplicates). Every plop is dated plantedAt (YYYY-MM-DD), or UTC today
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { // when nil — the UI always sends its local day, so the default is for API and
// agent callers. Returns the plops it created.
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout) return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
} }
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object // fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
@@ -189,10 +191,13 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
// non-finite region, clamps the region to the object's bounds, refuses fills over // non-finite region, clamps the region to the object's bounds, refuses fills over
// maxFillPlops, and inserts the whole batch in one transaction rather than one // maxFillPlops, and inserts the whole batch in one transaction rather than one
// round-trip per plop. // round-trip per plop.
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
if !o.Plantable { if !o.Plantable {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
if !validDatePtr(plantedAt) {
return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
layout, ok := validFillLayout(layout) layout, ok := validFillLayout(layout)
if !ok { if !ok {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
@@ -235,7 +240,10 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
if err != nil { if err != nil {
return nil, err return nil, err
} }
today := s.now().UTC().Format(dateLayout) plantedOn := s.now().UTC().Format(dateLayout)
if plantedAt != nil {
plantedOn = *plantedAt
}
batch := make([]*domain.Planting, 0, len(centers)) batch := make([]*domain.Planting, 0, len(centers))
// Only the plops that were ALREADY here can cover a candidate: every plop this // Only the plops that were ALREADY here can cover a candidate: every plop this
// fill makes shares one radius and sits on a distinct lattice point, and a plop // fill makes shares one radius and sits on a distinct lattice point, and a plop
@@ -247,7 +255,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
if coveredByExisting(c.x, c.y, radius, existing) { if coveredByExisting(c.x, c.y, radius, existing) {
continue continue
} }
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}) batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn})
} }
created, err := s.store.CreatePlantings(ctx, batch) created, err := s.store.CreatePlantings(ctx, batch)
if err != nil { if err != nil {
@@ -376,7 +384,7 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half") // FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
// instead of a resolved Region — the ergonomic form for agent tools, which don't // instead of a resolved Region — the ergonomic form for agent tools, which don't
// hold the object's geometry. It resolves the name against the object, then fills. // hold the object's geometry. It resolves the name against the object, then fills.
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -385,7 +393,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout) return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
} }
// ClearObject soft-removes every active plop in an object the actor can edit (one // ClearObject soft-removes every active plop in an object the actor can edit (one
+43 -12
View File
@@ -59,7 +59,7 @@ func TestFillRegionCappedForHugeArea(t *testing.T) {
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrInvalidInput) { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err) t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
} }
} }
@@ -199,7 +199,7 @@ func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50}, {MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)}, {MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
} { } {
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump, nil)
if !errors.Is(err, domain.ErrInvalidInput) { if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err) t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
} }
@@ -223,7 +223,7 @@ func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50. // Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -256,7 +256,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15 plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -283,7 +283,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
// Re-filling the same region skips everything (each candidate sits exactly on // Re-filling the same region skips everything (each candidate sits exactly on
// an existing plop → entirely inside it). // an existing plop → entirely inside it).
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("second FillRegion: %v", err) t.Fatalf("second FillRegion: %v", err)
} }
@@ -305,14 +305,14 @@ func TestFillGridLaysOutIndividualPlants(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) // spacing 10 plant := seedOwnPlant(t, s, owner, 10) // spacing 10
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("clump: %v", err) t.Fatalf("clump: %v", err)
} }
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil { if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
t.Fatalf("clear: %v", err) t.Fatalf("clear: %v", err)
} }
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid) grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid, nil)
if err != nil { if err != nil {
t.Fatalf("grid: %v", err) t.Fatalf("grid: %v", err)
} }
@@ -351,7 +351,7 @@ func TestFillRejectsUnknownLayout(t *testing.T) {
bed := seedFillBed(t, s, owner, g.ID, 60, 60) bed := seedFillBed(t, s, owner, g.ID, 60, 60)
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral")); !errors.Is(err, domain.ErrInvalidInput) { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral"), nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err) t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
} }
} }
@@ -368,7 +368,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 20) plant := seedOwnPlant(t, s, owner, 20)
region, _ := NamedRegion(bed, "ne") region, _ := NamedRegion(bed, "ne")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -391,7 +391,7 @@ func TestClearObject(t *testing.T) {
bed := seedBed(t, s, owner, g.ID) bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); err != nil { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
@@ -425,7 +425,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
} }
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrForbidden) { if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("viewer fill = %v, want ErrForbidden", err) t.Errorf("viewer fill = %v, want ErrForbidden", err)
} }
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) { if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
@@ -455,7 +455,7 @@ func TestFillScenario(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("region %q: %v", name, err) t.Fatalf("region %q: %v", name, err)
} }
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump); err != nil { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump, nil); err != nil {
t.Fatalf("fill %q: %v", name, err) t.Fatalf("fill %q: %v", name, err)
} }
} }
@@ -507,3 +507,34 @@ func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingC
} }
return p return p
} }
// TestFillRegionPlantedAt: a fill dates its plops as told and refuses a date
// that isn't one. The UI sends its local day, so an evening fill isn't stamped
// with UTC's tomorrow; API and agent callers that omit it still get UTC today.
func TestFillRegionPlantedAt(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Dated", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 200, 100)
plant := seedOwnPlant(t, s, owner, 30)
day := "2026-04-01"
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &day)
if err != nil {
t.Fatalf("fill: %v", err)
}
if len(created) == 0 {
t.Fatal("fill created nothing")
}
for _, p := range created {
if p.PlantedAt == nil || *p.PlantedAt != day {
t.Errorf("planting %d plantedAt = %v, want %s", p.ID, p.PlantedAt, day)
}
}
bad := "April 1st"
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &bad); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
}
}
+5 -5
View File
@@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump) created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillNamedRegion: %v", err) t.Fatalf("FillNamedRegion: %v", err)
} }
@@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -725,7 +725,7 @@ func TestRevertResultCarriesItsCounts(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
// A second, different kind of change, so the breakdown has more than one row // A second, different kind of change, so the breakdown has more than one row
@@ -830,7 +830,7 @@ func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{ cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
Source: domain.SourceAgent, Summary: "plant beans in the second bed", Source: domain.SourceAgent, Summary: "plant beans in the second bed",
}, func(ctx context.Context) error { }, func(ctx context.Context) error {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
return err return err
} }
cancel() // the client disconnects, mid-turn, after the work landed cancel() // the client disconnects, mid-turn, after the work landed
+5 -2
View File
@@ -99,9 +99,12 @@ func TestRemainingReturnsWhenAPlantingIsRemoved(t *testing.T) {
lot := seedLot(t, s, owner, plant.ID, 100, nil) lot := seedLot(t, s, owner, plant.ID, 100, nil)
ctx := context.Background() ctx := context.Background()
ten := 10 // Dated explicitly: left to default, plantedAt is the real UTC day, and the
// removal below has to come after it — a test that only passed before
// 2026-08-01 is the kind of clock bomb this avoids.
ten, planted := 10, "2026-07-01"
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, PlantedAt: &planted,
}) })
if err != nil { if err != nil {
t.Fatalf("CreatePlanting: %v", err) t.Fatalf("CreatePlanting: %v", err)
+32 -8
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert' import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -6,24 +6,37 @@ import { Dialog } from '@/components/ui/Dialog'
import { TextField } from '@/components/ui/Field' import { TextField } from '@/components/ui/Field'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api' import { errorMessage } from '@/lib/api'
import { useCopyGarden, type Garden } from '@/lib/gardens' import { useCopyGarden, useGardens, type Garden } from '@/lib/gardens'
import { parsePlanName, planNameFor } from '@/lib/plan' import { nextPlanYear, parsePlanName, planNameFor } from '@/lib/plan'
/** /**
* Duplicate a garden — the way to scheme a season: the copy is a separate * Duplicate a garden — the way to scheme a season: the copy is a separate
* garden you rearrange freely while this one stays put. Beds and everything * garden you rearrange freely while this one stays put. Beds and everything
* currently planted come along; the share link and shares don't. The name is * currently planted come along; the share link and shares don't. The name is
* prefilled as "<name> — <next year>", which is what the editor's season * prefilled as "<name> — <year>" for the next year that doesn't already have a
* control and the `plan` tag read back (see lib/plan.ts). On success we land in * plan, which is what the editor's season control and the `plan` tag read back
* the copy, since the point of copying is to start editing it. * (see lib/plan.ts). On success we land in the copy, since the point of copying
* is to start editing it.
*/ */
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) { export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const copy = useCopyGarden() const copy = useCopyGarden()
const navigate = useNavigate() const navigate = useNavigate()
const gardens = useGardens()
const names = (gardens.data ?? []).map((g) => g.name)
const base = parsePlanName(garden.name)?.base ?? garden.name const base = parsePlanName(garden.name)?.base ?? garden.name
const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1 const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
const year = nextPlanYear(base, names, from)
const [name, setName] = useState(() => planNameFor(base, year)) const [name, setName] = useState(() => planNameFor(base, year))
const [touched, setTouched] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// The gardens list can still be loading when this opens; until the person
// edits the name, keep the proposal in step with what the list says is free.
useEffect(() => {
if (!touched) setName(planNameFor(base, year))
}, [base, year, touched])
// The API allows duplicate names; say so rather than let two gardens read as
// the same season's plan.
const taken = names.some((n) => n.trim() === name.trim())
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
@@ -45,8 +58,19 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in rearrange freely, the A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in rearrange freely, the
original stays put. Beds and what's planted come along; shares and the public link don't. original stays put. Beds and what's planted come along; shares and the public link don't.
</p> </p>
<TextField label="Name" name="name" required autoFocus value={name} onChange={(e) => setName(e.target.value)} /> <TextField
label="Name"
name="name"
required
autoFocus
value={name}
onChange={(e) => {
setTouched(true)
setName(e.target.value)
}}
/>
<p className="text-xs text-ink-mute">Keep the {year} and it shows up as that season's plan in the editor.</p> <p className="text-xs text-ink-mute">Keep the {year} and it shows up as that season's plan in the editor.</p>
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} disabled={copy.isPending}> <Button type="button" onClick={onClose} disabled={copy.isPending}>
+12 -7
View File
@@ -5,7 +5,7 @@ import { IconButton } from '@/components/ui/Button'
import { Tag } from '@/components/ui/Tag' import { Tag } from '@/components/ui/Tag'
import type { Garden } from '@/lib/gardens' import type { Garden } from '@/lib/gardens'
import { useGardenFull } from '@/lib/objects' import { useGardenFull } from '@/lib/objects'
import { planYearOf } from '@/lib/plan' import { parsePlanName, planYearOf } from '@/lib/plan'
import { sharesQueryOptions } from '@/lib/shares' import { sharesQueryOptions } from '@/lib/shares'
import { formatSize } from '@/lib/units' import { formatSize } from '@/lib/units'
import { kindPlural } from '@/editor/kinds' import { kindPlural } from '@/editor/kinds'
@@ -15,10 +15,11 @@ import { GardenThumb } from './GardenThumb'
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure'] const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
/** /**
* One garden as a card: the plot thumbnail (a link into the editor), name + an * One garden as a card: the plot thumbnail (a link into the editor), name, size,
* optional `plan` tag, size, a counts line, who it's shared with, and a footer * a counts line, who it's shared with, and a footer of Open + share / copy /
* of Open + share / copy / edit / delete. A garden shared WITH you shows its * edit / delete. A plan copy shows its base name with a `<year> plan` tag. A
* role and a leave action instead of the owner's tools. * garden shared WITH you shows its role and a leave action instead of the
* owner's tools.
*/ */
export function GardenCard({ export function GardenCard({
garden, garden,
@@ -40,7 +41,11 @@ export function GardenCard({
const owner = currentUserId != null && garden.ownerId === currentUserId const owner = currentUserId != null && garden.ownerId === currentUserId
const full = useGardenFull(garden.id) const full = useGardenFull(garden.id)
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner }) const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
const plan = parsePlanName(garden.name)
const planYear = planYearOf(garden.name) const planYear = planYearOf(garden.name)
// A plan's year is the point of its name, and the first thing truncation
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
const title = planYear != null && plan ? plan.base : garden.name
const meta = useMemo(() => { const meta = useMemo(() => {
const data = full.data const data = full.data
@@ -79,9 +84,9 @@ export function GardenCard({
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4"> <div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}> <span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
{garden.name} {title}
</span> </span>
{planYear != null && <Tag tone="accent">plan</Tag>} {planYear != null && <Tag tone="accent">{planYear} plan</Tag>}
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute"> <span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)} {formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
</span> </span>
+35 -31
View File
@@ -9,13 +9,15 @@ import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens' import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import { import {
cmFromFtIn, cmFromFtIn,
convertDimensionField,
dimensionField,
dimensionInputMode, dimensionInputMode,
dimensionUnitLabel, dimensionUnitLabel,
editDimensionField,
formatCm, formatCm,
formatDimensionInput,
isValidDimensionCm, isValidDimensionCm,
MIN_GARDEN_GRID_CM, MIN_GARDEN_GRID_CM,
parseDimension, type LengthField,
type UnitPref, type UnitPref,
} from '@/lib/units' } from '@/lib/units'
@@ -36,8 +38,11 @@ function entryHint(unit: UnitPref): string {
/** /**
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the * "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
* chosen unit and stored as centimeters; switching units converts what's typed so * chosen unit and stored as centimeters: each field is a LengthField, so
* the physical size holds. A 409 rebases the form onto the server's fresh row. * switching units re-shows the same centimeters and a Save sends exactly what
* was loaded unless the person typed over it (re-parsing the display string is
* how 900 cm once became 899.922). A 409 rebases the form onto the server's
* fresh row.
*/ */
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) { export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
const isEdit = !!garden const isEdit = !!garden
@@ -48,14 +53,10 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial' const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
const [name, setName] = useState(garden?.name ?? '') const [name, setName] = useState(garden?.name ?? '')
const [unit, setUnit] = useState<UnitPref>(initialUnit) const [unit, setUnit] = useState<UnitPref>(initialUnit)
const [width, setWidth] = useState(() => const [width, setWidth] = useState<LengthField>(() => dimensionField(garden?.widthCm ?? cmFromFtIn(DEFAULT_W_FT), initialUnit))
garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'), const [height, setHeight] = useState<LengthField>(() => dimensionField(garden?.heightCm ?? cmFromFtIn(DEFAULT_H_FT), initialUnit))
) const [gridSize, setGridSize] = useState<LengthField>(() =>
const [height, setHeight] = useState(() => dimensionField(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'),
)
const [gridSize, setGridSize] = useState(() =>
formatDimensionInput(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
) )
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false) const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '') const [notes, setNotes] = useState(garden?.notes ?? '')
@@ -65,17 +66,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
const [formError, setFormError] = useState<string | null>(null) const [formError, setFormError] = useState<string | null>(null)
function changeUnit(next: UnitPref) { function changeUnit(next: UnitPref) {
const convert = (s: string) => { setWidth((f) => convertDimensionField(f, next))
const cm = parseDimension(s, unit) setHeight((f) => convertDimensionField(f, next))
return cm === null ? s : formatDimensionInput(cm, next) setGridSize((f) => convertDimensionField(f, next))
}
setWidth(convert(width))
setHeight(convert(height))
setGridSize(convert(gridSize))
setUnit(next) setUnit(next)
} }
const gridSizeCm = parseDimension(gridSize, unit) const gridSizeCm = gridSize.cm
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
@@ -86,8 +83,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
setFormError('Give the garden a name.') setFormError('Give the garden a name.')
return return
} }
const widthCm = parseDimension(width, unit) const widthCm = width.cm
const heightCm = parseDimension(height, unit) const heightCm = height.cm
if (widthCm === null || heightCm === null) { if (widthCm === null || heightCm === null) {
setFormError(entryHint(unit)) setFormError(entryHint(unit))
return return
@@ -101,6 +98,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
return return
} }
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid } const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid }
// Nothing changed: close without a request. A PATCH that writes the same
// row still bumps the version and lands an "Edited garden settings" step
// in History that undoes nothing.
if (isEdit && (Object.keys(input) as (keyof typeof input)[]).every((k) => input[k] === garden[k])) {
onClose()
return
}
try { try {
if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version }) if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version })
else await create.mutateAsync(input) else await create.mutateAsync(input)
@@ -111,9 +115,9 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
setVersion(current.version) setVersion(current.version)
setName(current.name) setName(current.name)
setUnit(current.unitPref) setUnit(current.unitPref)
setWidth(formatDimensionInput(current.widthCm, current.unitPref)) setWidth(dimensionField(current.widthCm, current.unitPref))
setHeight(formatDimensionInput(current.heightCm, current.unitPref)) setHeight(dimensionField(current.heightCm, current.unitPref))
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref)) setGridSize(dimensionField(current.gridSizeCm, current.unitPref))
setSnapToGrid(current.snapToGrid) setSnapToGrid(current.snapToGrid)
setNotes(current.notes) setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.') setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
@@ -138,8 +142,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
required required
value={width} value={width.text}
onChange={(e) => setWidth(e.target.value)} onChange={(e) => setWidth(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
<TextField <TextField
@@ -148,8 +152,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
required required
value={height} value={height.text}
onChange={(e) => setHeight(e.target.value)} onChange={(e) => setHeight(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
<div className="field"> <div className="field">
@@ -171,8 +175,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
name="gridSize" name="gridSize"
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
value={gridSize} value={gridSize.text}
onChange={(e) => setGridSize(e.target.value)} onChange={(e) => setGridSize(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
hint={ hint={
gridTooFine && gridSizeCm !== null gridTooFine && gridSizeCm !== null
+2 -1
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { localToWorld } from '@/lib/geometry' import { localToWorld } from '@/lib/geometry'
import type { FullGarden } from '@/lib/objects' import type { FullGarden } from '@/lib/objects'
import { FALLBACK_PLANT_COLOR } from '@/lib/plants'
import { objectStyle, rectRadius } from '@/editor/kinds' import { objectStyle, rectRadius } from '@/editor/kinds'
/** /**
@@ -72,7 +73,7 @@ export function GardenThumb({
const o = byId.get(p.objectId) const o = byId.get(p.objectId)
if (!o) return null if (!o) return null
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg) const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? '#97a97c'} /> return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? FALLBACK_PLANT_COLOR} />
})} })}
</svg> </svg>
) )
+5 -3
View File
@@ -1,7 +1,9 @@
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { monogramInk } from '@/lib/monogram'
/** A plant marker off the canvas: a solid circle in the plant's color with its /** A plant marker off the canvas: a solid circle in the plant's color with its
* 12 letter monogram in the display face. Size in px. */ * 12 letter monogram in the display face, paper or ink by the color's
* lightness (see monogramInk). Size in px. */
export function Monogram({ export function Monogram({
color, color,
letters, letters,
@@ -16,8 +18,8 @@ export function Monogram({
return ( return (
<span <span
aria-hidden aria-hidden
className={cn('grid flex-none place-items-center rounded-full font-heading leading-none text-paper', className)} className={cn('grid flex-none place-items-center rounded-full font-heading leading-none', className)}
style={{ width: size, height: size, background: color, fontSize: Math.round(size * 0.425) }} style={{ width: size, height: size, background: color, color: monogramInk(color), fontSize: Math.round(size * 0.425) }}
> >
{letters} {letters}
</span> </span>
+1 -1
View File
@@ -95,7 +95,7 @@ export function PlantCard({
<Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}> <Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}>
Record a lot Record a lot
</Button> </Button>
<span className="ml-auto flex gap-1"> <span className="ml-auto flex flex-wrap justify-end gap-1">
<Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}> <Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}>
Duplicate Duplicate
</Button> </Button>
+17 -9
View File
@@ -15,7 +15,7 @@ import {
type PlantInput, type PlantInput,
} from '@/lib/plants' } from '@/lib/plants'
import { safeExternalUrl } from '@/lib/seedLots' import { safeExternalUrl } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units' import { editSpacingField, spacingField, spacingUnitLabel, type LengthField, type UnitPref } from '@/lib/units'
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches' import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
// Markers are monograms now, but the API still carries an icon per plant; a // Markers are monograms now, but the API still carries an icon per plant; a
@@ -26,8 +26,10 @@ const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY
/** /**
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another * "A new plant" — or edit (`plant`), or a fresh create prefilled from another
* (`template`, the Duplicate action; the only way to customize a built-in). * (`template`, the Duplicate action; the only way to customize a built-in).
* Spacing is typed in the page's unit and stored in centimeters. A 409 rebases * Spacing is typed in the page's unit and stored in centimeters — as a
* onto the server's current row. * LengthField, so a Save that didn't touch it sends the centimeters that were
* loaded rather than re-parsing "17.7 in" into 44.958. A 409 rebases onto the
* server's current row.
*/ */
export function PlantDialog({ export function PlantDialog({
plant, plant,
@@ -48,7 +50,7 @@ export function PlantDialog({
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '') const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable') const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit))) const [spacing, setSpacing] = useState<LengthField>(() => spacingField(source?.spacingCm ?? 30, unit))
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0])) const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
const [vendor, setVendor] = useState(source?.vendor ?? '') const [vendor, setVendor] = useState(source?.vendor ?? '')
@@ -68,8 +70,8 @@ export function PlantDialog({
setFormError('Give the plant a name.') setFormError('Give the plant a name.')
return return
} }
const spacingCm = cmFromSpacing(parseFloat(spacing), unit) const spacingCm = spacing.cm
if (!Number.isFinite(spacingCm) || spacingCm < 1) { if (spacingCm === null || spacingCm < 1) {
setFormError(`Spacing must be at least 1 ${unitLabel}.`) setFormError(`Spacing must be at least 1 ${unitLabel}.`)
return return
} }
@@ -97,6 +99,12 @@ export function PlantDialog({
vendor: vendor.trim(), vendor: vendor.trim(),
notes: notes.trim(), notes: notes.trim(),
} }
// Nothing changed: close without a request, so a look-and-Save doesn't bump
// the version for every garden that shares the plant.
if (isEdit && (Object.keys(input) as (keyof PlantInput)[]).every((k) => input[k] === (k === 'color' ? expandHex(plant.color) : plant[k]))) {
onClose()
return
}
try { try {
if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version }) if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version })
else await create.mutateAsync(input) else await create.mutateAsync(input)
@@ -107,7 +115,7 @@ export function PlantDialog({
setVersion(current.version) setVersion(current.version)
setName(current.name) setName(current.name)
setCategory(current.category) setCategory(current.category)
setSpacing(String(spacingFromCm(current.spacingCm, unit))) setSpacing(spacingField(current.spacingCm, unit))
setColor(expandHex(current.color)) setColor(expandHex(current.color))
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '') setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
setVendor(current.vendor) setVendor(current.vendor)
@@ -142,8 +150,8 @@ export function PlantDialog({
step="any" step="any"
min="1" min="1"
required required
value={spacing} value={spacing.text}
onChange={(e) => setSpacing(e.target.value)} onChange={(e) => setSpacing(editSpacingField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
</div> </div>
+4 -3
View File
@@ -10,8 +10,9 @@ import {
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from 'react' } from 'react'
import { clampScale, type Point } from '@/lib/geometry' import { clampScale, type Point } from '@/lib/geometry'
import { monogramInk } from '@/lib/monogram'
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects' import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants' import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings' import type { EditorPlanting } from '@/lib/plantings'
import { formatSize } from '@/lib/units' import { formatSize } from '@/lib/units'
import { kindDef, objectStyle, rectRadius } from './kinds' import { kindDef, objectStyle, rectRadius } from './kinds'
@@ -589,7 +590,7 @@ export const Canvas = forwardRef<
> >
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */} {/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" /> <circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
<circle r={p.radiusCm} fill={plant?.color ?? '#97a97c'} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} /> <circle r={p.radiusCm} fill={plant?.color ?? FALLBACK_PLANT_COLOR} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
</g> </g>
) )
})} })}
@@ -642,7 +643,7 @@ export const Canvas = forwardRef<
textAnchor="middle" textAnchor="middle"
dominantBaseline="central" dominantBaseline="central"
fontSize={r * 1.05} fontSize={r * 1.05}
fill="var(--color-paper)" fill={monogramInk(plant?.color ?? FALLBACK_PLANT_COLOR)}
style={{ fontFamily: 'var(--font-heading)' }} style={{ fontFamily: 'var(--font-heading)' }}
> >
{letters.get(p.plantId) ?? '?'} {letters.get(p.plantId) ?? '?'}
+38 -3
View File
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ColorDot } from '@/components/plants/Monogram' import { ColorDot } from '@/components/plants/Monogram'
import { Button, IconButton } from '@/components/ui/Button' import { Button, IconButton } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { TextAreaField, TextField } from '@/components/ui/Field' import { TextAreaField, TextField } from '@/components/ui/Field'
import { Icon } from '@/components/ui/Icon' import { Icon } from '@/components/ui/Icon'
import { Tag } from '@/components/ui/Tag' import { Tag } from '@/components/ui/Tag'
import { Toggle } from '@/components/ui/Toggle' import { Toggle } from '@/components/ui/Toggle'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects' import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants' import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings' import type { EditorPlanting } from '@/lib/plantings'
import { import {
cmFromSpacing, cmFromSpacing,
@@ -22,7 +23,7 @@ import {
spacingUnitLabel, spacingUnitLabel,
type UnitPref, type UnitPref,
} from '@/lib/units' } from '@/lib/units'
import { kindDef, kindPlural } from './kinds' import { kindDef, kindPlural, objectDisplayName } from './kinds'
import { MIN_OBJECT_CM, plopCount } from './shared' import { MIN_OBJECT_CM, plopCount } from './shared'
import { useEditorStore } from './store' import { useEditorStore } from './store'
import type { EditorObject } from './types' import type { EditorObject } from './types'
@@ -40,6 +41,13 @@ export function rosterText(o: EditorObject, plantings: EditorPlanting[], plantsB
return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ') return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ')
} }
/** How many plants an object holds right now (its plops × their counts). */
export function plantCountIn(o: EditorObject, plantings: EditorPlanting[], plantsById: Map<number, Plant>): number {
let n = 0
for (const p of plantings) if (p.objectId === o.id) n += plopCount(p, plantsById.get(p.plantId))
return n
}
/** A collapsible block of the less-often-needed fields. */ /** A collapsible block of the less-often-needed fields. */
function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) { function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) {
return ( return (
@@ -66,6 +74,7 @@ export function ObjectInspector({
canEdit, canEdit,
focused, focused,
roster, roster,
plantCount,
noteCount, noteCount,
large, large,
onPlantThis, onPlantThis,
@@ -79,6 +88,8 @@ export function ObjectInspector({
/** Already inside this bed (so "Plant this" is redundant). */ /** Already inside this bed (so "Plant this" is redundant). */
focused: boolean focused: boolean
roster: string roster: string
/** Live plants in it (see plantCountIn) — Remove asks first when this is > 0. */
plantCount: number
noteCount: number noteCount: number
/** Phone: 16px inputs, 44px targets. */ /** Phone: 16px inputs, 44px targets. */
large?: boolean large?: boolean
@@ -89,6 +100,7 @@ export function ObjectInspector({
const update = useUpdateObject(gardenId) const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId) const del = useDeleteObject(gardenId)
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const [name, setName] = useState(object.name) const [name, setName] = useState(object.name)
const [details, setDetails] = useState(false) const [details, setDetails] = useState(false)
const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit)) const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit))
@@ -178,6 +190,13 @@ export function ObjectInspector({
iconClassName="text-accent-700" iconClassName="text-accent-700"
disabled={del.isPending} disabled={del.isPending}
onClick={() => { onClick={() => {
// An empty object goes straight away (one Undo brings it back); a
// planted one takes its plants with it, which is worth a question —
// on the phone this button sits right beside "Plant this".
if (plantCount > 0) {
setConfirmRemove(true)
return
}
onDeleted() onDeleted()
del.mutate(object.id) del.mutate(object.id)
}} }}
@@ -259,6 +278,22 @@ export function ObjectInspector({
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} /> <TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} />
</fieldset> </fieldset>
</Details> </Details>
{confirmRemove && (
<ConfirmDialog
title={`Remove ${objectDisplayName(object)}?`}
confirmLabel="Remove it"
busyLabel="Removing…"
errorFallback="Could not remove it."
onConfirm={async () => {
await del.mutateAsync(object.id)
onDeleted()
}}
onClose={() => setConfirmRemove(false)}
>
It has {plantCount === 1 ? 'one plant' : `${plantCount} plants`} in it, and they go with it. One Undo brings
everything back.
</ConfirmDialog>
)}
</div> </div>
) )
} }
@@ -321,7 +356,7 @@ export function PlopInspector({
return ( return (
<div ref={rootRef} className="flex flex-col gap-3"> <div ref={rootRef} className="flex flex-col gap-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<ColorDot color={plant?.color ?? '#97a97c'} size={18} /> <ColorDot color={plant?.color ?? FALLBACK_PLANT_COLOR} size={18} />
<span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span> <span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span>
{noteCount > 0 && ( {noteCount > 0 && (
<button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}> <button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}>
+1 -1
View File
@@ -4,9 +4,9 @@ import { Button, IconButton } from '@/components/ui/Button'
import { TextAreaField, TextField } from '@/components/ui/Field' import { TextAreaField, TextField } from '@/components/ui/Field'
import { errorMessage } from '@/lib/api' import { errorMessage } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { today } from '@/lib/dates'
import { import {
formatObservedAt, formatObservedAt,
today,
useCreateJournalEntry, useCreateJournalEntry,
useDeleteJournalEntry, useDeleteJournalEntry,
useJournal, useJournal,
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { today } from './dates'
describe('today', () => {
it('is the local calendar day, zero-padded', () => {
// 21:30 local on Aug 22 is Aug 23 in UTC for anyone west of Greenwich; the
// gardener still planted on the 22nd.
expect(today(new Date(2026, 7, 22, 21, 30))).toBe('2026-08-22')
expect(today(new Date(2026, 0, 5, 0, 1))).toBe('2026-01-05')
})
})
+11
View File
@@ -0,0 +1,11 @@
// The day it is where the person is. Everything the UI stamps with "today" — a
// journal note, a placed or filled plant — uses the browser's local date,
// because a gardener planting at 9 pm in Ohio planted today, not (in UTC)
// tomorrow. The server's own defaults are UTC and only apply when a date is
// omitted, which is for API callers and the agent; the UI never omits one.
/** Today as YYYY-MM-DD in the browser's local time zone. */
export function today(now = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
-7
View File
@@ -127,13 +127,6 @@ export function useDeleteJournalEntry(gardenId: number) {
}) })
} }
/** Today as YYYY-MM-DD in the viewer's own timezone — "today" means the day you
* are standing in the garden, not the day it is in UTC. */
export function today(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** A date-only string as a short human label, without dragging the value /** A date-only string as a short human label, without dragging the value
* through a Date (which would shift it by the timezone offset). */ * through a Date (which would shift it by the timezone offset). */
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { monogramFor, monogramMap, speciesName } from './monogram' import { monogramFor, monogramInk, monogramMap, speciesName } from './monogram'
describe('speciesName', () => { describe('speciesName', () => {
it('drops a variety suffix and a parenthetical', () => { it('drops a variety suffix and a parenthetical', () => {
@@ -65,3 +65,22 @@ describe('monogramMap', () => {
expect(monogramFor('🌱')).toBe('?') expect(monogramFor('🌱')).toBe('?')
}) })
}) })
describe('monogramInk', () => {
it('keeps paper lettering on colors dark enough to carry it', () => {
expect(monogramInk('#c8553d')).toBe('var(--color-paper)') // tomato
expect(monogramInk('#7a8a5e')).toBe('var(--color-paper)') // sage
expect(monogramInk('#5f8f45')).toBe('var(--color-paper)')
})
it('switches to the dark marker ink on pale colors', () => {
expect(monogramInk('#d9d2c5')).toBe('var(--color-marker-ink)') // garlic
expect(monogramInk('#8bc98b')).toBe('var(--color-marker-ink)') // cabbage
expect(monogramInk('#fff')).toBe('var(--color-marker-ink)')
})
it('falls back to paper for anything it cannot read', () => {
expect(monogramInk('tomato')).toBe('var(--color-paper)')
expect(monogramInk('')).toBe('var(--color-paper)')
})
})
+40
View File
@@ -61,3 +61,43 @@ export function monogramFor(name: string): string {
const ls = letters(name) const ls = letters(name)
return ls.length ? ls[0].toUpperCase() : '?' return ls.length ? ls[0].toUpperCase() : '?'
} }
// --- Lettering color --------------------------------------------------------
// Letters are paper on the marker's color — which reads on tomato red and sage,
// and vanishes on garlic's #d9d2c5. Pale markers take the dark marker ink
// instead. Both inks are theme-stable (the marker's own color is), so the
// choice depends only on the color, never on light/dark mode.
const PAPER = 'var(--color-paper)'
const INK = 'var(--color-marker-ink)'
// Below this relative luminance, paper still clears ~2.5:1 against the marker;
// above it, the dark ink does better. Sage (#7a8a5e, 0.23) keeps paper; cabbage
// green (#8bc98b, 0.49), marigold orange and garlic flip to ink.
const PAPER_MAX_LUMINANCE = 0.37
/** WCAG relative luminance of a #rgb / #rrggbb color; null if unparseable. */
function luminance(color: string): number | null {
const m = color.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i)
if (!m) return null
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]
const channel = (i: number) => {
const v = parseInt(hex.slice(i, i + 2), 16) / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
}
// Memoized by color string: the canvas asks for every visible plop on every
// frame of a pan, and a catalog has a dozen distinct colors, not thousands.
const inkByColor = new Map<string, string>()
/** The CSS color the monogram letters take on a marker of `color`. */
export function monogramInk(color: string): string {
let ink = inkByColor.get(color)
if (ink === undefined) {
const l = luminance(color)
ink = l !== null && l > PAPER_MAX_LUMINANCE ? INK : PAPER
inkByColor.set(color, ink)
}
return ink
}
+8 -4
View File
@@ -7,6 +7,7 @@ import { useCallback } from 'react'
import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { z } from 'zod' import { z } from 'zod'
import { ApiError, api } from './api' import { ApiError, api } from './api'
import { today } from './dates'
import { gardenSchema } from './gardens' import { gardenSchema } from './gardens'
import { plantSchema, type Plant } from './plants' import { plantSchema, type Plant } from './plants'
import { serverPlantingSchema, type ServerPlanting } from './plantings' import { serverPlantingSchema, type ServerPlanting } from './plantings'
@@ -260,13 +261,17 @@ export interface PlantingCreate {
label?: string | null label?: string | null
/** Attributes the plop to a purchase, so that lot can report what's left. */ /** Attributes the plop to a purchase, so that lot can report what's left. */
seedLotId?: number seedLotId?: number
/** YYYY-MM-DD; defaults to the browser's local today (never the server's UTC one). */
plantedAt?: string
} }
export function useCreatePlanting(gardenId: number) { export function useCreatePlanting(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> => mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)), serverPlantingSchema.parse(
await api.post(`/objects/${objectId}/plantings`, { plantedAt: today(), ...body }),
),
onSuccess: (created) => { onSuccess: (created) => {
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] })) patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] }))
}, },
@@ -353,7 +358,7 @@ export function useFillObject(gardenId: number) {
layout: FillLayout layout: FillLayout
}): Promise<number> => { }): Promise<number> => {
const res = fillResultSchema.parse( const res = fillResultSchema.parse(
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }), await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout, plantedAt: today() }),
) )
return res.created return res.created
}, },
@@ -393,8 +398,7 @@ export function useRemovePlanting(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => { mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => {
const today = new Date().toISOString().slice(0, 10) return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today(), version }))
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version }))
}, },
onMutate: async ({ id }) => { onMutate: async ({ id }) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) }) await qc.cancelQueries({ queryKey: fullKey(gardenId) })
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan' import { nextPlanYear, parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan'
describe('plan names', () => { describe('plan names', () => {
it('round-trips the default copy name', () => { it('round-trips the default copy name', () => {
@@ -35,3 +35,15 @@ describe('plan names', () => {
expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2]) expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2])
}) })
}) })
describe('nextPlanYear', () => {
it('skips years that already have a plan copy, however the dash was typed', () => {
const names = ['Back Yard', 'Back Yard — 2027', 'Back Yard - 2028', 'Front Strip — 2029']
expect(nextPlanYear('Back Yard', names, 2027)).toBe(2029)
expect(nextPlanYear('Front Strip', names, 2027)).toBe(2027)
})
it('is the starting year when nothing is taken', () => {
expect(nextPlanYear('Plot', [], 2030)).toBe(2030)
})
})
+14
View File
@@ -40,3 +40,17 @@ export function planGardensOf<G extends { id: number; name: string }>(base: stri
} }
return out.sort((a, b) => a.year - b.year) return out.sort((a, b) => a.year - b.year)
} }
/** The first year from `from` on that `base` has no plan copy for among
* `names`, so a new copy never proposes a name that is already taken — two
* "Back Yard — 2027"s would both be offered as the 2027 plan. */
export function nextPlanYear(base: string, names: readonly string[], from: number): number {
const taken = new Set<number>()
for (const n of names) {
const p = parsePlanName(n)
if (p && p.base === base.trim()) taken.add(p.year)
}
let year = from
while (taken.has(year)) year += 1
return year
}
+4
View File
@@ -8,6 +8,10 @@ import { z } from 'zod'
import { ApiError, api } from './api' import { ApiError, api } from './api'
export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const
/** The marker color for a plop whose plant isn't in the catalog you can see (a
* shared garden's private plant): the sage the curated swatches start with. */
export const FALLBACK_PLANT_COLOR = '#97a97c'
export type PlantCategory = (typeof PLANT_CATEGORIES)[number] export type PlantCategory = (typeof PLANT_CATEGORIES)[number]
export const CATEGORY_LABELS: Record<PlantCategory, string> = { export const CATEGORY_LABELS: Record<PlantCategory, string> = {
+32
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
cmFromFtIn, cmFromFtIn,
convertDimensionField,
dimensionField,
editDimensionField,
editSpacingField,
spacingField,
cmFromMeters, cmFromMeters,
cmFromSpacing, cmFromSpacing,
dimensionInputMode, dimensionInputMode,
@@ -242,3 +247,30 @@ describe('formatSize / formatLength', () => {
expect(formatLength(1219, 'imperial')).toBe('40') expect(formatLength(1219, 'imperial')).toBe('40')
}) })
}) })
describe('LengthField', () => {
it('keeps the stored centimeters through a unit switch and back', () => {
let f = dimensionField(900, 'imperial')
expect(f.text).toBe('29 6.3″')
f = convertDimensionField(f, 'metric')
expect(f.text).toBe('9')
f = convertDimensionField(f, 'imperial')
expect(f).toEqual({ text: '29 6.3″', cm: 900 })
})
it('moves the centimeters only when the text is edited', () => {
expect(editDimensionField("15' 6\"", 'imperial').cm).toBe(472.44)
const typo = editDimensionField('nope', 'imperial')
expect(typo.cm).toBeNull()
// A typo survives a unit switch as typed rather than turning into a number.
expect(convertDimensionField(typo, 'metric')).toEqual(typo)
})
it('spacing: 45 cm reads 17.7 in and stays 45 cm until typed over', () => {
const f = spacingField(45, 'imperial')
expect(f).toEqual({ text: '17.7', cm: 45 })
expect(editSpacingField('18', 'imperial').cm).toBe(45.72)
expect(editSpacingField('', 'imperial').cm).toBeNull()
expect(editSpacingField('25', 'metric').cm).toBe(25)
})
})
+42
View File
@@ -227,3 +227,45 @@ export function formatLength(cm: number, unit: UnitPref): string {
export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string { export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string {
return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}` return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}`
} }
// --- Typed-length fields ----------------------------------------------------
// A dialog field that takes a length holds TWO things: the text the person sees
// and the centimeters it means. The centimeters change only when the person
// types; re-showing the field in another unit, or saving without touching it,
// reuses them as they are. Parsing the displayed text back on save is how a
// no-change Save once turned 900 cm into 899.922 — "29 6.3″" is the nearest
// tenth of an inch, not the number that was loaded.
/** A length as typed and as stored; `cm` is null while the text doesn't parse. */
export interface LengthField {
text: string
cm: number | null
}
/** A dimension field (meters, or feet and inches) showing `cm`. */
export function dimensionField(cm: number, unit: UnitPref): LengthField {
return { text: formatDimensionInput(cm, unit), cm }
}
/** The person typed `text` into a dimension field. */
export function editDimensionField(text: string, unit: UnitPref): LengthField {
return { text, cm: parseDimension(text, unit) }
}
/** Re-show a dimension field in another unit; the centimeters don't move. A
* field that doesn't parse keeps its text, so the typo stays visible. */
export function convertDimensionField(field: LengthField, unit: UnitPref): LengthField {
return field.cm === null ? field : dimensionField(field.cm, unit)
}
/** A spacing field (cm, or inches) showing `cm`. */
export function spacingField(cm: number, unit: UnitPref): LengthField {
return { text: String(spacingFromCm(cm, unit)), cm }
}
/** The person typed `text` into a spacing field. */
export function editSpacingField(text: string, unit: UnitPref): LengthField {
const trimmed = text.trim()
const n = Number(trimmed)
return { text, cm: trimmed !== '' && Number.isFinite(n) ? cmFromSpacing(n, unit) : null }
}
+2 -1
View File
@@ -13,7 +13,7 @@ import { AssistantTab } from '@/editor/AssistantTab'
import { Canvas, type CanvasHandle } from '@/editor/Canvas' import { Canvas, type CanvasHandle } from '@/editor/Canvas'
import { ClearBedDialog } from '@/editor/ClearBedDialog' import { ClearBedDialog } from '@/editor/ClearBedDialog'
import { HistoryTab } from '@/editor/HistoryTab' import { HistoryTab } from '@/editor/HistoryTab'
import { GardenSummary, ObjectInspector, PlopInspector, rosterText } from '@/editor/Inspector' import { GardenSummary, ObjectInspector, PlopInspector, plantCountIn, rosterText } from '@/editor/Inspector'
import { JournalTab, type JournalAttach } from '@/editor/JournalTab' import { JournalTab, type JournalAttach } from '@/editor/JournalTab'
import { KindSwatch } from '@/editor/KindSwatch' import { KindSwatch } from '@/editor/KindSwatch'
import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds' import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds'
@@ -390,6 +390,7 @@ function Editor({
canEdit={canEdit} canEdit={canEdit}
focused={focusId === selectedObject.id} focused={focusId === selectedObject.id}
roster={rosterText(selectedObject, plantings, plantsById)} roster={rosterText(selectedObject, plantings, plantsById)}
plantCount={plantCountIn(selectedObject, plantings, plantsById)}
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0} noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
large={isMobile} large={isMobile}
onPlantThis={() => plantThis(selectedObject)} onPlantThis={() => plantThis(selectedObject)}
+28 -9
View File
@@ -113,11 +113,16 @@ function WhoGetsInCard({ data }: { data: SettingsResponse }) {
) )
} }
type ModelField = 'agentModel' | 'visionModel'
function AssistantCard({ data }: { data: SettingsResponse }) { function AssistantCard({ data }: { data: SettingsResponse }) {
const update = useUpdateSettings() const update = useUpdateSettings()
const { settings, effective } = data const { settings, effective } = data
const [model, setModel] = useState(settings.agentModel) const [model, setModel] = useState(settings.agentModel)
const [vModel, setVModel] = useState(settings.visionModel) const [vModel, setVModel] = useState(settings.visionModel)
// A rejected model spec stays in its field with the server's reason under it
// (the field keeps the typo so it can be fixed); everything else toasts.
const [fieldError, setFieldError] = useState<{ field: ModelField; message: string } | null>(null)
// Re-sync the fields when the stored row changes underneath (a save, a // Re-sync the fields when the stored row changes underneath (a save, a
// refetch), so what's shown is what's saved. // refetch), so what's shown is what's saved.
@@ -136,14 +141,20 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
version: settings.version, version: settings.version,
}, },
{ {
onSuccess: () => toast.info('Saved the assistant picked it up.'), onSuccess: () => {
setFieldError(null)
toast.info('Saved — the assistant picked it up.')
},
onError: (err) => { onError: (err) => {
const current = conflictSettings(err) const current = conflictSettings(err)
toast.error( if (current) {
current toast.error('Someone else changed these settings just now — showing their version. Re-apply yours if you still want it.')
? 'Someone else changed these settings just now showing their version. Re-apply yours if you still want it.' return
: errorMessage(err, "Couldn't save settings."), }
) const message = errorMessage(err, "Couldn't save settings.")
const field = (['agentModel', 'visionModel'] as const).find((f) => patch[f] !== undefined)
if (field) setFieldError({ field, message })
else toast.error(message)
}, },
}, },
) )
@@ -157,10 +168,15 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
? 'off — key still in env' ? 'off — key still in env'
: `configured · not running (${shortModel(effective.model)})` : `configured · not running (${shortModel(effective.model)})`
const commit = (field: 'agentModel' | 'visionModel', value: string) => { const commit = (field: ModelField, value: string) => {
const v = value.trim() const v = value.trim()
if (v !== settings[field]) save({ [field]: v }) if (v !== settings[field]) save({ [field]: v })
// Back to what's saved (the typo was cleared): nothing to send, and the
// old reason would be about a value no longer in the field.
else setFieldError((e) => (e?.field === field ? null : e))
} }
const errorFor = (field: ModelField) =>
fieldError?.field === field ? <span className="text-accent-700">{fieldError.message}</span> : null
return ( return (
<Card title="Garden assistant" tag={<Tag tone="accent-2">{status}</Tag>}> <Card title="Garden assistant" tag={<Tag tone="accent-2">{status}</Tag>}>
@@ -180,7 +196,7 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
onBlur={() => commit('agentModel', model)} onBlur={() => commit('agentModel', model)}
onKeyDown={(e) => e.key === 'Enter' && commit('agentModel', model)} onKeyDown={(e) => e.key === 'Enter' && commit('agentModel', model)}
hint="A majordomo model spec; a comma-separated list is a failover chain." hint={errorFor('agentModel') ?? 'A majordomo model spec; a comma-separated list is a failover chain.'}
/> />
<TextField <TextField
label="Vision model — reads seed packets; must be vision-capable" label="Vision model — reads seed packets; must be vision-capable"
@@ -190,7 +206,10 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
onChange={(e) => setVModel(e.target.value)} onChange={(e) => setVModel(e.target.value)}
onBlur={() => commit('visionModel', vModel)} onBlur={() => commit('visionModel', vModel)}
onKeyDown={(e) => e.key === 'Enter' && commit('visionModel', vModel)} onKeyDown={(e) => e.key === 'Enter' && commit('visionModel', vModel)}
hint={effective.visionReady ? `Scanning is on with ${shortModel(effective.visionModel)}.` : 'Scanning is off until a model and a key are both present.'} hint={
errorFor('visionModel') ??
(effective.visionReady ? `Scanning is on with ${shortModel(effective.visionModel)}.` : 'Scanning is off until a model and a key are both present.')
}
/> />
<div className="text-xs leading-relaxed text-ink-mute"> <div className="text-xs leading-relaxed text-ink-mute">
The API key stays in the environment on purpose a secret in the database would ride along in every backup. The API key stays in the environment on purpose a secret in the database would ride along in every backup.
+4 -1
View File
@@ -21,8 +21,11 @@
--color-accent: #c67139; --color-accent: #c67139;
--color-accent-2: #7a8a5e; --color-accent-2: #7a8a5e;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent); --color-divider: color-mix(in srgb, #201e1d 16%, transparent);
/* The monogram ink on plant markers; does not change between modes. */ /* Monogram lettering on plant markers: paper on dark colors, marker-ink on
pale ones (lib/monogram.ts picks). Neither changes between modes — the
marker's own color doesn't either. */
--color-paper: #fffaf1; --color-paper: #fffaf1;
--color-marker-ink: #201e1d;
--color-neutral-100: #f9f4ed; --color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db; --color-neutral-200: #eee7db;