From 27f658c1f765cd7bb3fab9158d9c08017aeed1ba Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 22 Aug 2026 22:11:12 -0400 Subject: [PATCH 1/3] Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 15 ++++++ DESIGN.md | 2 +- internal/agent/runtime_test.go | 2 +- internal/agent/tools.go | 4 +- internal/agent/tools_test.go | 2 +- internal/api/errors.go | 18 ++++++- internal/api/errors_test.go | 30 +++++++++++ internal/api/ops.go | 8 ++- internal/api/settings_test.go | 18 ++++++- internal/domain/domain.go | 5 +- internal/service/instance_settings.go | 26 ++++++--- internal/service/ops.go | 24 ++++++--- internal/service/ops_test.go | 55 ++++++++++++++----- internal/service/revisions_test.go | 10 ++-- internal/service/seed_lots_test.go | 7 ++- web/src/components/gardens/CopyDialog.tsx | 20 ++++--- web/src/components/gardens/GardenCard.tsx | 13 +++-- web/src/components/gardens/GardenDialog.tsx | 59 ++++++++++----------- web/src/components/plants/Monogram.tsx | 8 +-- web/src/components/plants/PlantCard.tsx | 2 +- web/src/components/plants/PlantDialog.tsx | 20 +++---- web/src/editor/Canvas.tsx | 9 +++- web/src/editor/Inspector.tsx | 37 ++++++++++++- web/src/lib/dates.test.ts | 11 ++++ web/src/lib/dates.ts | 11 ++++ web/src/lib/journal.ts | 9 +--- web/src/lib/monogram.test.ts | 21 +++++++- web/src/lib/monogram.ts | 31 +++++++++++ web/src/lib/objects.ts | 12 +++-- web/src/lib/plan.test.ts | 14 ++++- web/src/lib/plan.ts | 14 +++++ web/src/lib/units.test.ts | 32 +++++++++++ web/src/lib/units.ts | 42 +++++++++++++++ web/src/pages/GardenEditorPage.tsx | 3 +- web/src/pages/SettingsPage.tsx | 34 ++++++++---- web/src/styles/index.css | 5 +- 36 files changed, 508 insertions(+), 125 deletions(-) create mode 100644 internal/api/errors_test.go create mode 100644 web/src/lib/dates.test.ts create mode 100644 web/src/lib/dates.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1d9b23a..2223ad3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,21 @@ Conventions that follow from it: a constraint between neighbouring plants; a bed edge is nobody's neighbour. - **Soft removal**: "clear bed" sets `removed_at`; the editor reads `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 guards the same way. +- **"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 at startup, embedded. Never edit one that has shipped. - **Every service mutation lands in history** (#48). If you add one, record it — diff --git a/DESIGN.md b/DESIGN.md index a3e7ac4..f6e5cff 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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/objects PATCH,DELETE /objects/: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 GET,POST /plants PATCH,DELETE /plants/:id (own plants only) GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private) diff --git a/internal/agent/runtime_test.go b/internal/agent/runtime_test.go index a25945d..e452d10 100644 --- a/internal/agent/runtime_test.go +++ b/internal/agent/runtime_test.go @@ -60,7 +60,7 @@ func TestTurnIsOneChangeSet(t *testing.T) { if err != nil { 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) } diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 43571dc..a62d951 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -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"` 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) { - 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 { diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index cf19ae1..2324285 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -169,7 +169,7 @@ func TestGarlicBedToCucumbers(t *testing.T) { if err != nil { 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) } diff --git a/internal/api/errors.go b/internal/api/errors.go index 83bc538..d4417df 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" @@ -52,7 +53,7 @@ func writeServiceError(c *gin.Context, err error) { case errors.Is(err, domain.ErrOIDCIdentityConflict): writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account") case errors.Is(err, domain.ErrInvalidInput): - writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input") + writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err)) default: slog.Error("api: unhandled service error", "error", err) writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error") @@ -114,3 +115,18 @@ func parseIDParam(c *gin.Context, name string) (int64, bool) { } 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+": ") +} diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go new file mode 100644 index 0000000..5ab138d --- /dev/null +++ b/internal/api/errors_test.go @@ -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) + } + } +} diff --git a/internal/api/ops.go b/internal/api/ops.go index e152109..cf72063 100644 --- a/internal/api/ops.go +++ b/internal/api/ops.go @@ -55,6 +55,10 @@ type objectFillRequest struct { // (individual plants in rows at true spacing). Empty = clump. An unknown value // is refused by the service (#77). 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) { @@ -88,9 +92,9 @@ func (h *handlers) fillObject(c *gin.Context) { return } 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 { - 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 { writeServiceError(c, err) diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 4dc304a..3198376 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "net/http" + "strings" "testing" "github.com/gin-gonic/gin" @@ -159,10 +161,22 @@ func TestSettingsRejectsBadModel(t *testing.T) { admin := registerAndCookie(t, r, "admin@example.com") v := settingsVersion(t, r, admin) - if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", - map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest { + w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", + map[string]any{"agentModel": "nonesuch/model", "version": v}, admin) + if w.Code != http.StatusBadRequest { 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. if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest { diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 2fa7c34..ffbee70 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -33,7 +33,10 @@ var ( ErrShareExists = errors.New("garden already shared with that user") // 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") // ErrInvalidCredentials means a login attempt failed. It is deliberately // identical for an unknown email and a wrong password so neither can be diff --git a/internal/service/instance_settings.go b/internal/service/instance_settings.go index 6a121c5..5ea56fb 100644 --- a/internal/service/instance_settings.go +++ b/internal/service/instance_settings.go @@ -2,6 +2,8 @@ package service import ( "context" + "errors" + "fmt" "strings" "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) vision := strings.TrimSpace(patch.VisionModel) // 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. - for _, spec := range []string{model, vision} { - if spec != "" { - if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil { - return nil, domain.ErrInvalidInput - } + // sentinel and needs no check — the env value was validated at boot. The + // reason rides on the sentinel so the 400 can show it: "unknown provider" + // is something a person can act on, "invalid input" is not. + for _, f := range []struct{ label, spec string }{{"chat model", model}, {"vision model", vision}} { + 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{ @@ -166,3 +171,12 @@ func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, Effectiv } 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() +} diff --git a/internal/service/ops.go b/internal/service/ops.go index b648fe4..743134f 100644 --- a/internal/service/ops.go +++ b/internal/service/ops.go @@ -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- // 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 -// stack duplicates). Returns the plops it created. -func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { +// stack duplicates). Every plop is dated plantedAt (YYYY-MM-DD), or UTC today +// 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) if err != nil { 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 @@ -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 // maxFillPlops, and inserts the whole batch in one transaction rather than one // 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 { 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) if !ok { return nil, domain.ErrInvalidInput @@ -235,7 +240,10 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde if err != nil { 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)) // 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 @@ -247,7 +255,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde if coveredByExisting(c.x, c.y, radius, existing) { 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) 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") // 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. -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) if err != nil { return nil, err @@ -385,7 +393,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, if err != nil { 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 diff --git a/internal/service/ops_test.go b/internal/service/ops_test.go index ba2810a..1fa58b4 100644 --- a/internal/service/ops_test.go +++ b/internal/service/ops_test.go @@ -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 plant := seedOwnPlant(t, s, owner, 10) 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) } } @@ -199,7 +199,7 @@ func TestFillRegionRejectsNonFiniteRegion(t *testing.T) { {MinX: nan, MinY: -50, MaxX: 50, MaxY: 50}, {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) { 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) // 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 { 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 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 { 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 // 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 { t.Fatalf("second FillRegion: %v", err) } @@ -305,14 +305,14 @@ func TestFillGridLaysOutIndividualPlants(t *testing.T) { plant := seedOwnPlant(t, s, owner, 10) // spacing 10 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 { t.Fatalf("clump: %v", err) } if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil { 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 { t.Fatalf("grid: %v", err) } @@ -351,7 +351,7 @@ func TestFillRejectsUnknownLayout(t *testing.T) { bed := seedFillBed(t, s, owner, g.ID, 60, 60) plant := seedOwnPlant(t, s, owner, 10) 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) } } @@ -368,7 +368,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) { plant := seedOwnPlant(t, s, owner, 20) 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 { t.Fatalf("FillRegion: %v", err) } @@ -391,7 +391,7 @@ func TestClearObject(t *testing.T) { bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 10) 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) } @@ -425,7 +425,7 @@ func TestOpsForbiddenForViewer(t *testing.T) { } 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) } 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 { 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) } } @@ -507,3 +507,34 @@ func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingC } 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, "a@example.com") + 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) + } +} diff --git a/internal/service/revisions_test.go b/internal/service/revisions_test.go index ecc500c..751ae2f 100644 --- a/internal/service/revisions_test.go +++ b/internal/service/revisions_test.go @@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) { plant := seedOwnPlant(t, s, owner, 15) 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 { t.Fatalf("FillNamedRegion: %v", err) } @@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) { plant := seedOwnPlant(t, s, owner, 15) 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) } before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) @@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) { plant := seedOwnPlant(t, s, owner, 15) 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) } before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) @@ -725,7 +725,7 @@ func TestRevertResultCarriesItsCounts(t *testing.T) { plant := seedOwnPlant(t, s, owner, 15) 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) } // 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{ Source: domain.SourceAgent, Summary: "plant beans in the second bed", }, 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 } cancel() // the client disconnects, mid-turn, after the work landed diff --git a/internal/service/seed_lots_test.go b/internal/service/seed_lots_test.go index a9cb75d..a2f0059 100644 --- a/internal/service/seed_lots_test.go +++ b/internal/service/seed_lots_test.go @@ -99,9 +99,12 @@ func TestRemainingReturnsWhenAPlantingIsRemoved(t *testing.T) { lot := seedLot(t, s, owner, plant.ID, 100, nil) 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{ - 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 { t.Fatalf("CreatePlanting: %v", err) diff --git a/web/src/components/gardens/CopyDialog.tsx b/web/src/components/gardens/CopyDialog.tsx index 04fbd57..a741a0b 100644 --- a/web/src/components/gardens/CopyDialog.tsx +++ b/web/src/components/gardens/CopyDialog.tsx @@ -6,24 +6,31 @@ import { Dialog } from '@/components/ui/Dialog' import { TextField } from '@/components/ui/Field' import { toast } from '@/components/ui/toast' import { errorMessage } from '@/lib/api' -import { useCopyGarden, type Garden } from '@/lib/gardens' -import { parsePlanName, planNameFor } from '@/lib/plan' +import { useCopyGarden, useGardens, type Garden } from '@/lib/gardens' +import { nextPlanYear, parsePlanName, planNameFor } from '@/lib/plan' /** * 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 * currently planted come along; the share link and shares don't. The name is - * prefilled as "", which is what the editor's season - * control and the `plan` tag read back (see lib/plan.ts). On success we land in - * the copy, since the point of copying is to start editing it. + * prefilled as "" for the next year that doesn't already have a + * plan, which is what the editor's season control and the `plan` tag read back + * (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 }) { const copy = useCopyGarden() const navigate = useNavigate() + const gardens = useGardens() + const names = (gardens.data ?? []).map((g) => g.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 [error, setError] = useState(null) + // 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) { e.preventDefault() @@ -47,6 +54,7 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>

setName(e.target.value)} />

Keep the “— {year}” and it shows up as that season's plan in the editor.

+ {taken && You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.} {error && {error}}
- + diff --git a/web/src/components/plants/PlantDialog.tsx b/web/src/components/plants/PlantDialog.tsx index fcd5ebb..4cbfe8e 100644 --- a/web/src/components/plants/PlantDialog.tsx +++ b/web/src/components/plants/PlantDialog.tsx @@ -15,7 +15,7 @@ import { type PlantInput, } from '@/lib/plants' 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' // 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 * (`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 - * onto the server's current row. + * Spacing is typed in the page's unit and stored in centimeters — as a + * 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({ plant, @@ -48,7 +50,7 @@ export function PlantDialog({ const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '') const [category, setCategory] = useState(source?.category ?? 'vegetable') - const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit))) + const [spacing, setSpacing] = useState(() => spacingField(source?.spacingCm ?? 30, unit)) const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0])) const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') const [vendor, setVendor] = useState(source?.vendor ?? '') @@ -68,8 +70,8 @@ export function PlantDialog({ setFormError('Give the plant a name.') return } - const spacingCm = cmFromSpacing(parseFloat(spacing), unit) - if (!Number.isFinite(spacingCm) || spacingCm < 1) { + const spacingCm = spacing.cm + if (spacingCm === null || spacingCm < 1) { setFormError(`Spacing must be at least 1 ${unitLabel}.`) return } @@ -107,7 +109,7 @@ export function PlantDialog({ setVersion(current.version) setName(current.name) setCategory(current.category) - setSpacing(String(spacingFromCm(current.spacingCm, unit))) + setSpacing(spacingField(current.spacingCm, unit)) setColor(expandHex(current.color)) setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '') setVendor(current.vendor) @@ -142,8 +144,8 @@ export function PlantDialog({ step="any" min="1" required - value={spacing} - onChange={(e) => setSpacing(e.target.value)} + value={spacing.text} + onChange={(e) => setSpacing(editSpacingField(e.target.value, unit))} wrapperClassName="flex-1" />
diff --git a/web/src/editor/Canvas.tsx b/web/src/editor/Canvas.tsx index c3f2331..3021de2 100644 --- a/web/src/editor/Canvas.tsx +++ b/web/src/editor/Canvas.tsx @@ -10,6 +10,7 @@ import { type PointerEvent as ReactPointerEvent, } from 'react' import { clampScale, type Point } from '@/lib/geometry' +import { monogramInk } from '@/lib/monogram' import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects' import type { Plant } from '@/lib/plants' import type { EditorPlanting } from '@/lib/plantings' @@ -34,6 +35,10 @@ import { import { useEditorStore, type Viewport } from './store' import type { EditorGarden, EditorObject } from './types' +// A plop whose plant is missing from the catalog (a shared garden's private +// plant) still needs a color to be drawn in. +const FALLBACK_PLANT_COLOR = '#97a97c' + const WHEEL_SENSITIVITY = 0.0016 const ANIM_MS = 520 const REFIT_THRESHOLD_PX = 60 @@ -589,7 +594,7 @@ export const Canvas = forwardRef< > {/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */} - + ) })} @@ -642,7 +647,7 @@ export const Canvas = forwardRef< textAnchor="middle" dominantBaseline="central" fontSize={r * 1.05} - fill="var(--color-paper)" + fill={monogramInk(plant?.color ?? FALLBACK_PLANT_COLOR)} style={{ fontFamily: 'var(--font-heading)' }} > {letters.get(p.plantId) ?? '?'} diff --git a/web/src/editor/Inspector.tsx b/web/src/editor/Inspector.tsx index d8e6061..1e25c38 100644 --- a/web/src/editor/Inspector.tsx +++ b/web/src/editor/Inspector.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { ColorDot } from '@/components/plants/Monogram' import { Button, IconButton } from '@/components/ui/Button' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { TextAreaField, TextField } from '@/components/ui/Field' import { Icon } from '@/components/ui/Icon' import { Tag } from '@/components/ui/Tag' @@ -22,7 +23,7 @@ import { spacingUnitLabel, type UnitPref, } from '@/lib/units' -import { kindDef, kindPlural } from './kinds' +import { kindDef, kindPlural, objectDisplayName } from './kinds' import { MIN_OBJECT_CM, plopCount } from './shared' import { useEditorStore } from './store' 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(' · ') } +/** How many plants an object holds right now (its plops × their counts). */ +export function plantCountIn(o: EditorObject, plantings: EditorPlanting[], plantsById: Map): 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. */ function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) { return ( @@ -66,6 +74,7 @@ export function ObjectInspector({ canEdit, focused, roster, + plantCount, noteCount, large, onPlantThis, @@ -79,6 +88,8 @@ export function ObjectInspector({ /** Already inside this bed (so "Plant this" is redundant). */ focused: boolean roster: string + /** Live plants in it (see plantCountIn) — Remove asks first when this is > 0. */ + plantCount: number noteCount: number /** Phone: 16px inputs, 44px targets. */ large?: boolean @@ -89,6 +100,7 @@ export function ObjectInspector({ const update = useUpdateObject(gardenId) const del = useDeleteObject(gardenId) const rootRef = useRef(null) + const [confirmRemove, setConfirmRemove] = useState(false) const [name, setName] = useState(object.name) const [details, setDetails] = useState(false) const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit)) @@ -178,6 +190,13 @@ export function ObjectInspector({ iconClassName="text-accent-700" disabled={del.isPending} 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() del.mutate(object.id) }} @@ -259,6 +278,22 @@ export function ObjectInspector({ setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} /> + {confirmRemove && ( + { + 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. + + )} ) } diff --git a/web/src/lib/dates.test.ts b/web/src/lib/dates.test.ts new file mode 100644 index 0000000..b0cbfbf --- /dev/null +++ b/web/src/lib/dates.test.ts @@ -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') + }) +}) diff --git a/web/src/lib/dates.ts b/web/src/lib/dates.ts new file mode 100644 index 0000000..8f5eed2 --- /dev/null +++ b/web/src/lib/dates.ts @@ -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())}` +} diff --git a/web/src/lib/journal.ts b/web/src/lib/journal.ts index 772eaf6..b91f191 100644 --- a/web/src/lib/journal.ts +++ b/web/src/lib/journal.ts @@ -127,13 +127,8 @@ 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())}` -} +// "Today" for a note is the same local day everything else stamps (lib/dates.ts). +export { today } from './dates' /** A date-only string as a short human label, without dragging the value * through a Date (which would shift it by the timezone offset). */ diff --git a/web/src/lib/monogram.test.ts b/web/src/lib/monogram.test.ts index b291981..6ee105b 100644 --- a/web/src/lib/monogram.test.ts +++ b/web/src/lib/monogram.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { monogramFor, monogramMap, speciesName } from './monogram' +import { monogramFor, monogramInk, monogramMap, speciesName } from './monogram' describe('speciesName', () => { it('drops a variety suffix and a parenthetical', () => { @@ -65,3 +65,22 @@ describe('monogramMap', () => { 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)') + }) +}) diff --git a/web/src/lib/monogram.ts b/web/src/lib/monogram.ts index 8b63a60..fcde61f 100644 --- a/web/src/lib/monogram.ts +++ b/web/src/lib/monogram.ts @@ -61,3 +61,34 @@ export function monogramFor(name: string): string { const ls = letters(name) 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) +} + +/** The CSS color the monogram letters take on a marker of `color`. */ +export function monogramInk(color: string): string { + const l = luminance(color) + return l !== null && l > PAPER_MAX_LUMINANCE ? INK : PAPER +} diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index 4694dfc..6aa06f3 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -7,6 +7,7 @@ import { useCallback } from 'react' import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { z } from 'zod' import { ApiError, api } from './api' +import { today } from './dates' import { gardenSchema } from './gardens' import { plantSchema, type Plant } from './plants' import { serverPlantingSchema, type ServerPlanting } from './plantings' @@ -260,13 +261,17 @@ export interface PlantingCreate { label?: string | null /** Attributes the plop to a purchase, so that lot can report what's left. */ 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) { const qc = useQueryClient() return useMutation({ mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise => - serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)), + serverPlantingSchema.parse( + await api.post(`/objects/${objectId}/plantings`, { plantedAt: today(), ...body }), + ), onSuccess: (created) => { patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] })) }, @@ -353,7 +358,7 @@ export function useFillObject(gardenId: number) { layout: FillLayout }): Promise => { 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 }, @@ -393,8 +398,7 @@ export function useRemovePlanting(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async ({ id, version }: { id: number; version: number }): Promise => { - 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 }) => { await qc.cancelQueries({ queryKey: fullKey(gardenId) }) diff --git a/web/src/lib/plan.test.ts b/web/src/lib/plan.test.ts index dc97a70..c6d067c 100644 --- a/web/src/lib/plan.test.ts +++ b/web/src/lib/plan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan' +import { nextPlanYear, parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan' describe('plan names', () => { 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]) }) }) + +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) + }) +}) diff --git a/web/src/lib/plan.ts b/web/src/lib/plan.ts index 17183a1..21e8134 100644 --- a/web/src/lib/plan.ts +++ b/web/src/lib/plan.ts @@ -40,3 +40,17 @@ export function planGardensOf(base: stri } 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() + 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 +} diff --git a/web/src/lib/units.test.ts b/web/src/lib/units.test.ts index 8cdb0e2..afc6a89 100644 --- a/web/src/lib/units.test.ts +++ b/web/src/lib/units.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { cmFromFtIn, + convertDimensionField, + dimensionField, + editDimensionField, + editSpacingField, + spacingField, cmFromMeters, cmFromSpacing, dimensionInputMode, @@ -242,3 +247,30 @@ describe('formatSize / formatLength', () => { 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) + }) +}) diff --git a/web/src/lib/units.ts b/web/src/lib/units.ts index 93e23fa..16a1648 100644 --- a/web/src/lib/units.ts +++ b/web/src/lib/units.ts @@ -227,3 +227,45 @@ export function formatLength(cm: number, unit: UnitPref): string { export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string { 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 } +} diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index 5ec0446..d5cadc3 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -13,7 +13,7 @@ import { AssistantTab } from '@/editor/AssistantTab' import { Canvas, type CanvasHandle } from '@/editor/Canvas' import { ClearBedDialog } from '@/editor/ClearBedDialog' 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 { KindSwatch } from '@/editor/KindSwatch' import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds' @@ -390,6 +390,7 @@ function Editor({ canEdit={canEdit} focused={focusId === selectedObject.id} roster={rosterText(selectedObject, plantings, plantsById)} + plantCount={plantCountIn(selectedObject, plantings, plantsById)} noteCount={journalCounts.data?.get(selectedObject.id) ?? 0} large={isMobile} onPlantThis={() => plantThis(selectedObject)} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 84a996b..31c693c 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -113,11 +113,16 @@ function WhoGetsInCard({ data }: { data: SettingsResponse }) { ) } +type ModelField = 'agentModel' | 'visionModel' + function AssistantCard({ data }: { data: SettingsResponse }) { const update = useUpdateSettings() const { settings, effective } = data const [model, setModel] = useState(settings.agentModel) 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 // refetch), so what's shown is what's saved. @@ -136,14 +141,20 @@ function AssistantCard({ data }: { data: SettingsResponse }) { 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) => { const current = conflictSettings(err) - toast.error( - current - ? 'Someone else changed these settings just now — showing their version. Re-apply yours if you still want it.' - : errorMessage(err, "Couldn't save settings."), - ) + if (current) { + toast.error('Someone else changed these settings just now — showing their version. Re-apply yours if you still want it.') + return + } + 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,12 @@ function AssistantCard({ data }: { data: SettingsResponse }) { ? 'off — key still in env' : `configured · not running (${shortModel(effective.model)})` - const commit = (field: 'agentModel' | 'visionModel', value: string) => { + const commit = (field: ModelField, value: string) => { const v = value.trim() if (v !== settings[field]) save({ [field]: v }) } + const errorFor = (field: ModelField) => + fieldError?.field === field ? {fieldError.message} : null return ( {status}}> @@ -180,7 +193,7 @@ function AssistantCard({ data }: { data: SettingsResponse }) { onChange={(e) => setModel(e.target.value)} onBlur={() => 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.'} /> setVModel(e.target.value)} onBlur={() => 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.') + } />
The API key stays in the environment on purpose — a secret in the database would ride along in every backup. diff --git a/web/src/styles/index.css b/web/src/styles/index.css index 75e919c..a850d2e 100644 --- a/web/src/styles/index.css +++ b/web/src/styles/index.css @@ -21,8 +21,11 @@ --color-accent: #c67139; --color-accent-2: #7a8a5e; --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-marker-ink: #201e1d; --color-neutral-100: #f9f4ed; --color-neutral-200: #eee7db; From 157e04ed244c673abaea0306dd1badba3c998a6d Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 22 Aug 2026 22:22:22 -0400 Subject: [PATCH 2/3] Skip no-op saves in the edit dialogs; clear a stale model-spec error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/gardens/GardenDialog.tsx | 7 +++++++ web/src/components/plants/PlantDialog.tsx | 6 ++++++ web/src/pages/SettingsPage.tsx | 3 +++ 3 files changed, 16 insertions(+) diff --git a/web/src/components/gardens/GardenDialog.tsx b/web/src/components/gardens/GardenDialog.tsx index 2d81e3e..12a0f49 100644 --- a/web/src/components/gardens/GardenDialog.tsx +++ b/web/src/components/gardens/GardenDialog.tsx @@ -98,6 +98,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () return } 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 { if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version }) else await create.mutateAsync(input) diff --git a/web/src/components/plants/PlantDialog.tsx b/web/src/components/plants/PlantDialog.tsx index 4cbfe8e..2adc3fa 100644 --- a/web/src/components/plants/PlantDialog.tsx +++ b/web/src/components/plants/PlantDialog.tsx @@ -99,6 +99,12 @@ export function PlantDialog({ vendor: vendor.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 { if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version }) else await create.mutateAsync(input) diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 31c693c..0edc4fc 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -171,6 +171,9 @@ function AssistantCard({ data }: { data: SettingsResponse }) { const commit = (field: ModelField, value: string) => { const v = value.trim() 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 ? {fieldError.message} : null From 0d95578c6abbd1974721beb13ab5e5bc3b31bd6e Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 22 Aug 2026 22:25:02 -0400 Subject: [PATCH 3/3] Address #125 review: memoized ink, one fallback color, reactive copy name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 "" 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 --- CLAUDE.md | 5 ++++- web/src/components/gardens/CopyDialog.tsx | 20 ++++++++++++++++++-- web/src/components/gardens/GardenCard.tsx | 12 +++++++----- web/src/components/gardens/GardenThumb.tsx | 3 ++- web/src/editor/Canvas.tsx | 6 +----- web/src/editor/Inspector.tsx | 4 ++-- web/src/editor/JournalTab.tsx | 2 +- web/src/lib/journal.ts | 2 -- web/src/lib/monogram.ts | 13 +++++++++++-- web/src/lib/plants.ts | 4 ++++ 10 files changed, 50 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2223ad3..ff148e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,7 +134,10 @@ Conventions that follow from it: 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 guards the same way. + 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 diff --git a/web/src/components/gardens/CopyDialog.tsx b/web/src/components/gardens/CopyDialog.tsx index a741a0b..fdc107d 100644 --- a/web/src/components/gardens/CopyDialog.tsx +++ b/web/src/components/gardens/CopyDialog.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from 'react' +import { useEffect, useState, type FormEvent } from 'react' import { useNavigate } from '@tanstack/react-router' import { Alert } from '@/components/ui/Alert' import { Button } from '@/components/ui/Button' @@ -27,7 +27,13 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1 const year = nextPlanYear(base, names, from) const [name, setName] = useState(() => planNameFor(base, year)) + const [touched, setTouched] = useState(false) const [error, setError] = useState(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()) @@ -52,7 +58,17 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => A copy of {garden.name} to scheme in — rearrange freely, the original stays put. Beds and what's planted come along; shares and the public link don't.

- setName(e.target.value)} /> + { + setTouched(true) + setName(e.target.value) + }} + />

Keep the “— {year}” and it shows up as that season's plan in the editor.

{taken && You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.} {error && {error}} diff --git a/web/src/components/gardens/GardenCard.tsx b/web/src/components/gardens/GardenCard.tsx index 61b9c06..63f23f3 100644 --- a/web/src/components/gardens/GardenCard.tsx +++ b/web/src/components/gardens/GardenCard.tsx @@ -15,10 +15,11 @@ import { GardenThumb } from './GardenThumb' 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 (a - * plan copy shows its base name and a ` plan` tag), size, a counts line, who it's shared with, and a footer - * of Open + share / copy / edit / delete. A garden shared WITH you shows its - * role and a leave action instead of the owner's tools. + * One garden as a card: the plot thumbnail (a link into the editor), name, size, + * a counts line, who it's shared with, and a footer of Open + share / copy / + * edit / delete. A plan copy shows its base name with a ` plan` tag. A + * garden shared WITH you shows its role and a leave action instead of the + * owner's tools. */ export function GardenCard({ garden, @@ -40,10 +41,11 @@ export function GardenCard({ const owner = currentUserId != null && garden.ownerId === currentUserId const full = useGardenFull(garden.id) const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner }) + const plan = parsePlanName(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 ? (parsePlanName(garden.name)?.base ?? garden.name) : garden.name + const title = planYear != null && plan ? plan.base : garden.name const meta = useMemo(() => { const data = full.data diff --git a/web/src/components/gardens/GardenThumb.tsx b/web/src/components/gardens/GardenThumb.tsx index 3c0462d..a6d8c9a 100644 --- a/web/src/components/gardens/GardenThumb.tsx +++ b/web/src/components/gardens/GardenThumb.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { localToWorld } from '@/lib/geometry' import type { FullGarden } from '@/lib/objects' +import { FALLBACK_PLANT_COLOR } from '@/lib/plants' import { objectStyle, rectRadius } from '@/editor/kinds' /** @@ -72,7 +73,7 @@ export function GardenThumb({ const o = byId.get(p.objectId) if (!o) return null const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg) - return + return })} ) diff --git a/web/src/editor/Canvas.tsx b/web/src/editor/Canvas.tsx index 3021de2..3ff9058 100644 --- a/web/src/editor/Canvas.tsx +++ b/web/src/editor/Canvas.tsx @@ -12,7 +12,7 @@ import { import { clampScale, type Point } from '@/lib/geometry' import { monogramInk } from '@/lib/monogram' 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 { formatSize } from '@/lib/units' import { kindDef, objectStyle, rectRadius } from './kinds' @@ -35,10 +35,6 @@ import { import { useEditorStore, type Viewport } from './store' import type { EditorGarden, EditorObject } from './types' -// A plop whose plant is missing from the catalog (a shared garden's private -// plant) still needs a color to be drawn in. -const FALLBACK_PLANT_COLOR = '#97a97c' - const WHEEL_SENSITIVITY = 0.0016 const ANIM_MS = 520 const REFIT_THRESHOLD_PX = 60 diff --git a/web/src/editor/Inspector.tsx b/web/src/editor/Inspector.tsx index 1e25c38..b34f21c 100644 --- a/web/src/editor/Inspector.tsx +++ b/web/src/editor/Inspector.tsx @@ -8,7 +8,7 @@ import { Tag } from '@/components/ui/Tag' import { Toggle } from '@/components/ui/Toggle' import { cn } from '@/lib/cn' 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 { cmFromSpacing, @@ -356,7 +356,7 @@ export function PlopInspector({ return (
- + {plant?.name ?? 'Unknown plant'} {noteCount > 0 && (