Compare commits
46
Commits
afa9288b4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cac26286b1 | ||
|
|
c432fe9199 | ||
|
|
f14875557b | ||
|
|
7b150275ae | ||
|
|
9e227e29eb | ||
|
|
256fa4f29f | ||
|
|
7015148edf | ||
|
|
887a3c2cc6 | ||
|
|
ace696467b | ||
|
|
a72ddefc99 | ||
|
|
cf37e57808 | ||
|
|
08d8c5e47d | ||
|
|
e7b91de752 | ||
|
|
b0e11bce17 | ||
|
|
1323a03acd | ||
|
|
06b887f58e | ||
|
|
440e43eb78 | ||
|
|
9a8382c4a2 | ||
|
|
09e52a2d41 | ||
|
|
1b4bbf0a06 | ||
|
|
12de25e8f3 | ||
|
|
78ee892b64 | ||
|
|
b33bdeb9ff | ||
|
|
3b9c9086b6 | ||
|
|
1e0bf16a2a | ||
|
|
79df0df53f | ||
|
|
99798db8f6 | ||
|
|
11e8e1a544 | ||
|
|
7297138630 | ||
|
|
97c8a36bac | ||
|
|
90af03d597 | ||
|
|
8aad2278a0 | ||
|
|
e9df1f05d8 | ||
|
|
2d25b7e28e | ||
|
|
0ab90a01cb | ||
|
|
9ec626302b | ||
|
|
8a27df9e9c | ||
|
|
79f03acea8 | ||
|
|
1b11b2bd62 | ||
|
|
d8003b11fb | ||
|
|
9f434a801a | ||
|
|
14af9502d4 | ||
|
|
283010dccb | ||
|
|
3685308d10 | ||
|
|
c80cf15bf1 | ||
|
|
7c1faa1515 |
@@ -7,12 +7,13 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
|
||||
## Decisions
|
||||
|
||||
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
|
||||
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and the failure mode it prevents are written out once in `hexCenters`; #75 is what getting it wrong looked like.
|
||||
- **A fill is one of two operations (#77).** A plop is a *clump*, not a plant, which is the right primitive for SKETCHING ("a few plops of garlic in a corner") but can't draw a real planting — a filled bed comes out as ~15 blobs, not 8 rows of garlic. So `FillRegion`/`FillNamedRegion` take a `FillLayout`: `clump` (default; plop radius 1.5×spacing, ~7 plants each — quick coverage) or `grid` (radius spacing/2, pitch = spacing, ONE plant per plop — a layout you could plant from). Surfaced on `POST /objects/:id/fill` (`layout`) and the agent's `fill_region` (`mode`). Same centered `hexCenters` lattice for both, but BOTH the plop radius (`plopRadiusFor`) and the edge inset (`edgeInset`) differ by layout: a grid plant sits at the plop's centre, so it insets a half-spacing; a clump's plants reach its rim, so it insets radius-less-a-half and overhangs the edge by that half — reusing the clump formula for grid would inset by zero and plant flush on the edge. A grid-filled bed approaches the low-hundreds-of-plops the SVG budget was sized for, which the semantic-zoom tiers already anticipate.
|
||||
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and how it differs by layout are written out once in `edgeInset` (which `hexCenters` then honours); #75 is what getting it wrong looked like.
|
||||
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
|
||||
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
|
||||
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
||||
- **Instance settings (#79):** admin-editable, instance-wide config in a single-row `instance_settings` table — pansy's first DB-stored *instance* state (everything else hangs off a garden/object). Today it holds the agent model + on/off; **secrets never move here** — `OLLAMA_CLOUD_API_KEY` stays in the env so it doesn't land in backups or the undo history. Precedence: Settings value → env → default. The live agent Runner sits behind an `atomic.Pointer` in the API layer (`agentHolder`) with its routes always registered, so a settings change swaps it with no restart and no race against in-flight requests; `/capabilities` reads that pointer, so it reports what's live rather than what was configured at boot. The model/registry knowledge lives in one leaf package (`internal/agentmodel`) that both the runner and the settings validator import — agent imports service, so it can live in neither.
|
||||
- **Seed-packet capture (#81):** photograph a packet → a *vision* model (separate `vision_model` setting) reads it into structured fields via one-shot `majordomo.Generate[SeedPacket]` — NOT an agent loop, so the extraction can't touch the garden; it only reads a picture and returns data. The image is normalized to JPEG at the upload boundary (`internal/imagenorm`: decodes HEIC/webp/png/jpeg, since majordomo's stdlib media path can't do HEIC — the iPhone default). The hard part is **catalog matching, not OCR**: a wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates — it surfaces ranked candidates (`matchPlants`) and the user confirms, then `CreateFromPacket` makes the plant (new or existing) + the lot. Plants/lots aren't in the undo history (they're catalog/inventory), so there's no change set to wrap. The extractor is injectable on the service (`WithPacketExtractor`) so the whole path tests hermetically against majordomo's `fake` provider.
|
||||
- **Seed-packet capture (#81):** photograph a packet → a *vision* model (separate `vision_model` setting) reads it into structured fields via one-shot `majordomo.Generate[SeedPacket]` — NOT an agent loop, so the extraction can't touch the garden; it only reads a picture and returns data. The image is normalized to JPEG at the upload boundary (`internal/imagenorm`: decodes HEIC/webp/png/jpeg, since majordomo's stdlib media path can't do HEIC — the iPhone default; it also bakes in the JPEG EXIF orientation, since the re-encode strips EXIF and a phone photo tagged "rotate 90°" would otherwise reach the model sideways). The hard part is **catalog matching, not OCR**: a wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates — it surfaces ranked candidates (`matchPlants`) and the user confirms, then `CreateFromPacket` makes the plant (new or existing) + the lot. Plants/lots aren't in the undo history (they're catalog/inventory), so there's no change set to wrap. The extractor is injectable on the service (`WithPacketExtractor`) so the whole path tests hermetically against majordomo's `fake` provider.
|
||||
- **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
|
||||
|
||||
## Domain model
|
||||
@@ -127,7 +128,8 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
|
||||
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
|
||||
|
||||
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
||||
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
|
||||
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag, and the mobile `mode`.
|
||||
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (shown once a bed is focused; focusing a bed puts you in this mode — a "Recent" strip of what you've most recently planted *in this garden* (#100, derived from plantings, not the manual tray) for one-tap re-arming, the seed tray, the picker, and a clump/rows **fill** control that runs the region fill (#77) the UI couldn't reach before), **Journal**, **Assistant** (Assistant hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. The mode bar is **always visible**, and the rail (inspector, journal, history, assistant) is an **in-flow peek** (#101): a ≤50vh panel the editor's flex column places BETWEEN the canvas and the mode bar, so the canvas flexes to keep the garden visible above it and the mode bar reachable below — selecting a bed no longer hides the whole garden, and you can switch modes without closing a panel. Desktop keeps its side-column layout (the mode bar is `md:hidden`, the rail is the right column) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
|
||||
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
||||
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
||||
|
||||
|
||||
@@ -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); err != nil {
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
|
||||
t.Fatalf("seed garlic: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+113
-1
@@ -64,6 +64,40 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
||||
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
|
||||
"thing is. observedAt defaults to today; set it to backdate.",
|
||||
a.addJournalEntry),
|
||||
llm.DefineTool("read_journal",
|
||||
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
|
||||
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
|
||||
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+
|
||||
"or \"what happened last spring?\".",
|
||||
a.readJournal),
|
||||
llm.DefineTool("update_object",
|
||||
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
|
||||
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
|
||||
"you pass change. Needs the object's current version from describe_garden. Example: "+
|
||||
"\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+
|
||||
"sum.",
|
||||
a.updateObject),
|
||||
llm.DefineTool("delete_object",
|
||||
"Delete an object from a garden entirely, along with its plantings. This is the "+
|
||||
"counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+
|
||||
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
|
||||
a.deleteObject),
|
||||
llm.DefineTool("remove_planting",
|
||||
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
|
||||
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
|
||||
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
|
||||
"basil out of the corner\".",
|
||||
a.removePlanting),
|
||||
llm.DefineTool("list_seed_lots",
|
||||
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
|
||||
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+
|
||||
"remaining\" number find_plant reports.",
|
||||
a.listSeedLots),
|
||||
llm.DefineTool("record_seed_lot",
|
||||
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
|
||||
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
|
||||
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".",
|
||||
a.recordSeedLot),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,8 +161,9 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
|
||||
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"`
|
||||
PlantID int64 `json:"plantId" description:"plant to fill with"`
|
||||
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)
|
||||
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode))
|
||||
}
|
||||
|
||||
func (a *adapter) findPlant(ctx context.Context, args struct {
|
||||
@@ -176,3 +211,80 @@ func (a *adapter) clearObject(ctx context.Context, args struct {
|
||||
}
|
||||
return map[string]int{"cleared": n}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) readJournal(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
|
||||
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
|
||||
From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"`
|
||||
To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"`
|
||||
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
|
||||
}) (any, error) {
|
||||
q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset}
|
||||
if args.From != "" {
|
||||
q.From = &args.From
|
||||
}
|
||||
if args.To != "" {
|
||||
q.To = &args.To
|
||||
}
|
||||
entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// hasMore is actionable now: re-call with offset += len(entries) to page.
|
||||
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) updateObject(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to change"`
|
||||
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
|
||||
Name *string `json:"name" description:"optional new label"`
|
||||
WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"`
|
||||
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
|
||||
RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"`
|
||||
Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"`
|
||||
}) (any, error) {
|
||||
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{
|
||||
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
|
||||
RotationDeg: args.RotationDeg, Plantable: args.Plantable,
|
||||
}, args.Version)
|
||||
}
|
||||
|
||||
func (a *adapter) deleteObject(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"`
|
||||
}) (any, error) {
|
||||
if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"deleted": args.ObjectID}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) removePlanting(ctx context.Context, args struct {
|
||||
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
|
||||
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
|
||||
}) (any, error) {
|
||||
// Soft-remove via the service, so removed_at is stamped from the same
|
||||
// (injectable) clock clear_object uses rather than the adapter's wall clock.
|
||||
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version)
|
||||
}
|
||||
|
||||
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
||||
PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"`
|
||||
}) (any, error) {
|
||||
return a.svc.ListSeedLots(ctx, a.actor, args.PlantID)
|
||||
}
|
||||
|
||||
func (a *adapter) recordSeedLot(ctx context.Context, args struct {
|
||||
PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"`
|
||||
Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"`
|
||||
Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"`
|
||||
Vendor string `json:"vendor" description:"optional vendor name"`
|
||||
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"`
|
||||
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
|
||||
Notes string `json:"notes" description:"optional free-text notes"`
|
||||
}) (any, error) {
|
||||
return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{
|
||||
PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit,
|
||||
Vendor: args.Vendor, SourceURL: args.SourceURL,
|
||||
PackedForYear: args.PackedForYear, Notes: args.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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); err != nil {
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
|
||||
t.Fatalf("seed the garlic: %v", err)
|
||||
}
|
||||
|
||||
@@ -347,6 +347,127 @@ func TestJournalToolWritesADatedObservation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectiveTools covers the #85 gaps: the agent can now read the journal it
|
||||
// could only write, resize and delete an object it could only create and move,
|
||||
// pull a single plop instead of clearing the whole bed, and record/read seed
|
||||
// lots. Each is driven through the tool layer the way a model would run it.
|
||||
func TestCorrectiveTools(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
|
||||
var gid int64 // set once the garden exists; the describe closure reads it.
|
||||
call := func(name string, args any) llm.ToolResult {
|
||||
t.Helper()
|
||||
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
|
||||
}
|
||||
describe := func() service.DescribeResult {
|
||||
t.Helper()
|
||||
res := call("describe_garden", map[string]any{"gardenId": gid})
|
||||
if res.IsError {
|
||||
t.Fatalf("describe_garden: %s", res.Content)
|
||||
}
|
||||
var d service.DescribeResult
|
||||
if err := json.Unmarshal([]byte(res.Content), &d); err != nil {
|
||||
t.Fatalf("decode describe: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
gid = g.ID
|
||||
basil := mustPlant(t, svc, owner, "Basil", 25, "🌿")
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
|
||||
// update_object: "make that bed 100cm wider" — read the version, pass a new width.
|
||||
d := describe()
|
||||
if r := call("update_object", map[string]any{
|
||||
"objectId": bed.ID, "version": d.Objects[0].Version, "widthCm": 500.0,
|
||||
}); r.IsError {
|
||||
t.Fatalf("update_object: %s", r.Content)
|
||||
}
|
||||
if w := describe().Objects[0].WidthCM; w != 500 {
|
||||
t.Errorf("width = %v after update_object, want 500", w)
|
||||
}
|
||||
|
||||
// place a plop, then remove_planting it by id+version — one plop, not the bed.
|
||||
if r := call("place_planting", map[string]any{
|
||||
"objectId": bed.ID, "plantId": basil.ID, "xCm": 0, "yCm": 0, "radiusCm": 30,
|
||||
}); r.IsError {
|
||||
t.Fatalf("place_planting: %s", r.Content)
|
||||
}
|
||||
d = describe()
|
||||
if len(d.Objects[0].Plantings) != 1 {
|
||||
t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings))
|
||||
}
|
||||
plop := d.Objects[0].Plantings[0]
|
||||
if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError {
|
||||
t.Fatalf("remove_planting: %s", r.Content)
|
||||
}
|
||||
if n := len(describe().Objects[0].Plantings); n != 0 {
|
||||
t.Errorf("want 0 active plops after remove_planting, got %d", n)
|
||||
}
|
||||
|
||||
// add then read the journal — the write/read asymmetry the issue flagged.
|
||||
if r := call("add_journal_entry", map[string]any{
|
||||
"gardenId": g.ID, "objectId": bed.ID, "body": "aphids", "observedAt": "2026-06-01",
|
||||
}); r.IsError {
|
||||
t.Fatalf("add_journal_entry: %s", r.Content)
|
||||
}
|
||||
res := call("read_journal", map[string]any{"gardenId": g.ID, "objectId": bed.ID})
|
||||
if res.IsError {
|
||||
t.Fatalf("read_journal: %s", res.Content)
|
||||
}
|
||||
var jr struct {
|
||||
Entries []struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &jr); err != nil {
|
||||
t.Fatalf("decode read_journal: %v (%s)", err, res.Content)
|
||||
}
|
||||
if len(jr.Entries) != 1 || jr.Entries[0].Body != "aphids" {
|
||||
t.Errorf("read_journal = %+v, want the one aphids entry", jr.Entries)
|
||||
}
|
||||
|
||||
// record then list a seed lot — the detail behind find_plant's "remaining".
|
||||
if r := call("record_seed_lot", map[string]any{
|
||||
"plantId": basil.ID, "quantity": 2.0, "unit": "packets", "vendor": "Johnny's",
|
||||
}); r.IsError {
|
||||
t.Fatalf("record_seed_lot: %s", r.Content)
|
||||
}
|
||||
res = call("list_seed_lots", map[string]any{"plantId": basil.ID})
|
||||
if res.IsError {
|
||||
t.Fatalf("list_seed_lots: %s", res.Content)
|
||||
}
|
||||
var lots []struct {
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &lots); err != nil {
|
||||
t.Fatalf("decode list_seed_lots: %v (%s)", err, res.Content)
|
||||
}
|
||||
if len(lots) != 1 || lots[0].Quantity != 2 || lots[0].Unit != "packets" {
|
||||
t.Errorf("list_seed_lots = %+v, want one lot of 2 packets", lots)
|
||||
}
|
||||
|
||||
// delete_object: the counterpart to create_object.
|
||||
if r := call("delete_object", map[string]any{"objectId": bed.ID}); r.IsError {
|
||||
t.Fatalf("delete_object: %s", r.Content)
|
||||
}
|
||||
if n := len(describe().Objects); n != 0 {
|
||||
t.Errorf("want 0 objects after delete_object, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// newAgentTestService spins up an in-memory pansy with one registered user.
|
||||
func newAgentTestService(t *testing.T) (*service.Service, int64) {
|
||||
t.Helper()
|
||||
|
||||
+6
-2
@@ -51,6 +51,10 @@ type objectFillRequest struct {
|
||||
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
|
||||
// without editing the catalog entry.
|
||||
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
|
||||
// Layout is "clump" (default; fat clumps for a quick sketch) or "grid"
|
||||
// (individual plants in rows at true spacing). Empty = clump. An unknown value
|
||||
// is refused by the service (#77).
|
||||
Layout string `json:"layout"`
|
||||
}
|
||||
|
||||
func (h *handlers) fillObject(c *gin.Context) {
|
||||
@@ -84,9 +88,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)
|
||||
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
|
||||
} else {
|
||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM)
|
||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
|
||||
}
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
|
||||
@@ -97,6 +97,44 @@ func TestFillAndClearAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillLayoutAPI covers the layout selector (#77): grid packs denser than the
|
||||
// clump default, and an unknown layout is a 400 rather than a silent clump fill.
|
||||
func TestFillLayoutAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
||||
|
||||
// Clump (default) for the baseline count.
|
||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("clump fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
clumpN := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
||||
t.Fatalf("clear between fills: %d", w.Code)
|
||||
}
|
||||
|
||||
// Grid packs individual plants at true spacing — many more, small plops.
|
||||
w = doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all", "layout": "grid",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("grid fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gridN := int(decodeMap(t, w.Body.Bytes())["created"].(float64)); gridN <= clumpN {
|
||||
t.Errorf("grid fill created %d, want more than the clump fill's %d", gridN, clumpN)
|
||||
}
|
||||
|
||||
// An unknown layout is a 400, not a silent clump fill.
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all", "layout": "spiral",
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("unknown layout = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
|
||||
// its own corner of the bed.
|
||||
func TestFillRegionSelectionAPI(t *testing.T) {
|
||||
|
||||
+170
-10
@@ -28,6 +28,7 @@ package imagenorm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
@@ -104,9 +105,10 @@ var (
|
||||
)
|
||||
|
||||
// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP),
|
||||
// downscales it to fit opts.MaxDim on its longest edge, and returns it re-encoded
|
||||
// as JPEG, plus the decoded format name (e.g. "heic") — handy for logging what a
|
||||
// phone actually sent. On any error the returned bytes are nil and format is "".
|
||||
// downscales it to fit opts.MaxDim on its longest edge, applies the JPEG EXIF
|
||||
// orientation so the pixels come out upright, and returns it re-encoded as JPEG,
|
||||
// plus the decoded format name (e.g. "heic") — handy for logging what a phone
|
||||
// actually sent. On any error the returned bytes are nil and format is "".
|
||||
//
|
||||
// Errors, by cause:
|
||||
// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels /
|
||||
@@ -120,13 +122,18 @@ var (
|
||||
// pre-decode pixel/dimension check, and a recover around the third-party decoders
|
||||
// (a malformed HEIC/WebP shouldn't take the process down).
|
||||
//
|
||||
// Two known gaps, both deferred to the upload handler that wires this in (#81):
|
||||
// - EXIF ORIENTATION is not applied, so a portrait phone photo tagged
|
||||
// "rotate 90°" comes out sideways. That's best fixed and tested with a real
|
||||
// oriented photo end-to-end, which the library has no consumer for yet.
|
||||
// - There is no context: image.Decode is CPU-bound and not cancellable
|
||||
// mid-decode, so a caller that needs a hard deadline should run Normalize
|
||||
// under its own timeout. The size guards keep the work finite regardless.
|
||||
// EXIF orientation: phone cameras store the sensor pixels in one orientation and
|
||||
// set an EXIF tag to rotate on display, so a JPEG "portrait" photo is really a
|
||||
// landscape bitmap tagged "rotate 90°" — and the re-encode below strips EXIF,
|
||||
// which is exactly why the rotation must be BAKED IN here. applyOrientation does
|
||||
// that for the JPEG path (the format phone uploads overwhelmingly arrive in);
|
||||
// other formats carry no JPEG EXIF and their decoders own orientation, so they're
|
||||
// left as decoded.
|
||||
//
|
||||
// One known gap, deferred to the upload handler (#81): there is no context —
|
||||
// image.Decode is CPU-bound and not cancellable mid-decode, so a caller that
|
||||
// needs a hard deadline should run Normalize under its own timeout. The size
|
||||
// guards keep the work finite regardless.
|
||||
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
|
||||
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
|
||||
// maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway.
|
||||
@@ -161,7 +168,11 @@ func Normalize(r io.Reader, opts Options) (out []byte, format string, err error)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Downscale first (cheaper to rotate the small image), then bake in the EXIF
|
||||
// orientation so the JPEG we emit is upright. A 90° rotation swaps the sides
|
||||
// but not the longest edge, so the downscale bound still holds after it.
|
||||
img = downscale(img, opts.maxDim())
|
||||
img = applyOrientation(img, exifOrientation(raw))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||
@@ -188,6 +199,155 @@ func decodeSafely(raw []byte) (img image.Image, format string, err error) {
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// applyOrientation returns img with the EXIF orientation (1..8) baked in, so the
|
||||
// pixels are upright and no display-time rotation is needed. Orientation 1 (and
|
||||
// anything out of range) is a no-op. Values 5..8 are 90° rotations, which swap
|
||||
// the output's width and height. Copies raw RGBA pixels by byte offset (after a
|
||||
// one-time conversion if the source isn't already RGBA), so a full-resolution
|
||||
// rotation doesn't box a color.Color per pixel.
|
||||
func applyOrientation(img image.Image, o int) image.Image {
|
||||
if o <= 1 || o > 8 {
|
||||
return img
|
||||
}
|
||||
// Work on a concrete RGBA so the transform is a 4-byte copy per pixel rather
|
||||
// than millions of boxed color.Color values through At/Set. downscale usually
|
||||
// hands us an *image.RGBA already; convert once if not (e.g. a small JPEG that
|
||||
// skipped downscale decodes to YCbCr).
|
||||
src, ok := img.(*image.RGBA)
|
||||
if !ok {
|
||||
b := img.Bounds()
|
||||
conv := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(conv, conv.Bounds(), img, b.Min, draw.Src)
|
||||
src = conv
|
||||
}
|
||||
sb := src.Bounds()
|
||||
w, h := sb.Dx(), sb.Dy()
|
||||
// A quarter-turn (5..8) transposes the output; size the one buffer accordingly.
|
||||
dw, dh := w, h
|
||||
if o >= 5 {
|
||||
dw, dh = h, w
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
var dx, dy int
|
||||
switch o {
|
||||
case 2: // mirror horizontal
|
||||
dx, dy = w-1-x, y
|
||||
case 3: // rotate 180
|
||||
dx, dy = w-1-x, h-1-y
|
||||
case 4: // mirror vertical
|
||||
dx, dy = x, h-1-y
|
||||
case 5: // transpose (mirror across the main diagonal)
|
||||
dx, dy = y, x
|
||||
case 6: // rotate 90° clockwise
|
||||
dx, dy = h-1-y, x
|
||||
case 7: // transverse (mirror across the anti-diagonal)
|
||||
dx, dy = h-1-y, w-1-x
|
||||
case 8: // rotate 90° counter-clockwise
|
||||
dx, dy = y, w-1-x
|
||||
}
|
||||
si := src.PixOffset(sb.Min.X+x, sb.Min.Y+y)
|
||||
di := dst.PixOffset(dx, dy)
|
||||
copy(dst.Pix[di:di+4], src.Pix[si:si+4])
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// exifOrientation extracts the EXIF Orientation tag (1..8) from raw image bytes,
|
||||
// returning 1 (normal) when it's absent or unparseable — the safe default, since
|
||||
// a wrong guess rotates a correct image. Only the JPEG APP1/Exif path is parsed:
|
||||
// that's the format uploaded phone photos overwhelmingly arrive in, and the other
|
||||
// decoders own their own orientation.
|
||||
func exifOrientation(raw []byte) int {
|
||||
// A JPEG is a run of FFxx marker segments after the SOI (FFD8). Walk them
|
||||
// looking for APP1 (FFE1) carrying "Exif\0\0"; stop at the scan data (SOS).
|
||||
if len(raw) < 4 || raw[0] != 0xFF || raw[1] != 0xD8 {
|
||||
return 1
|
||||
}
|
||||
for i := 2; i+1 < len(raw); {
|
||||
if raw[i] != 0xFF {
|
||||
return 1 // not aligned on a marker; give up rather than misread
|
||||
}
|
||||
// A marker may be preceded by any number of 0xFF fill bytes (JPEG spec);
|
||||
// skip them so a padded APP1 isn't misread as a marker of value 0xFF.
|
||||
for i+1 < len(raw) && raw[i+1] == 0xFF {
|
||||
i++
|
||||
}
|
||||
if i+1 >= len(raw) {
|
||||
return 1
|
||||
}
|
||||
marker := raw[i+1]
|
||||
if marker == 0xD9 || marker == 0xDA {
|
||||
return 1 // EOI / start-of-scan: no more headers to read
|
||||
}
|
||||
if i+4 > len(raw) {
|
||||
return 1
|
||||
}
|
||||
segLen := int(raw[i+2])<<8 | int(raw[i+3])
|
||||
if segLen < 2 || i+2+segLen > len(raw) {
|
||||
return 1
|
||||
}
|
||||
if marker == 0xE1 {
|
||||
if o, ok := orientationFromApp1(raw[i+4 : i+2+segLen]); ok {
|
||||
return o
|
||||
}
|
||||
}
|
||||
i += 2 + segLen
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// orientationFromApp1 reads the Orientation tag from a JPEG APP1 segment body
|
||||
// (everything after the 2-byte length): "Exif\0\0" then a TIFF block holding
|
||||
// IFD0. Returns (0, false) if the segment isn't Exif or the tag is missing.
|
||||
func orientationFromApp1(seg []byte) (int, bool) {
|
||||
const prefix = "Exif\x00\x00"
|
||||
if len(seg) < len(prefix)+8 || string(seg[:len(prefix)]) != prefix {
|
||||
return 0, false
|
||||
}
|
||||
tiff := seg[len(prefix):]
|
||||
var bo binary.ByteOrder
|
||||
switch string(tiff[0:2]) {
|
||||
case "II":
|
||||
bo = binary.LittleEndian
|
||||
case "MM":
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if bo.Uint16(tiff[2:4]) != 0x2A { // TIFF magic (42); byte order must agree
|
||||
return 0, false
|
||||
}
|
||||
ifd := int(bo.Uint32(tiff[4:8])) // offset to IFD0 from the TIFF start
|
||||
if ifd < 8 || ifd+2 > len(tiff) {
|
||||
return 0, false
|
||||
}
|
||||
n := int(bo.Uint16(tiff[ifd : ifd+2]))
|
||||
for k := range n {
|
||||
off := ifd + 2 + k*12 // each IFD entry is 12 bytes
|
||||
if off+12 > len(tiff) {
|
||||
return 0, false
|
||||
}
|
||||
if bo.Uint16(tiff[off:off+2]) != 0x0112 { // Orientation tag
|
||||
continue
|
||||
}
|
||||
// Orientation is defined as a single SHORT, whose value sits inline in the
|
||||
// first 2 bytes of the value field. Reject anything else rather than read a
|
||||
// mistyped entry (a LONG/offset there would be a different number entirely).
|
||||
if bo.Uint16(tiff[off+2:off+4]) != 3 || bo.Uint32(tiff[off+4:off+8]) != 1 {
|
||||
return 0, false
|
||||
}
|
||||
v := int(bo.Uint16(tiff[off+8 : off+10]))
|
||||
if v >= 1 && v <= 8 {
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
|
||||
// aspect ratio. An image already within bounds is returned unchanged (no
|
||||
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
@@ -200,3 +201,126 @@ func TestNormalizeRejectsPixelBomb(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// orientedJPEG builds a JPEG whose top-left quadrant is white and the rest black
|
||||
// — a marker to track through a rotation — tagged with the given EXIF orientation
|
||||
// (1..8). The marker lets a test assert the pixels actually moved to where that
|
||||
// orientation says they should.
|
||||
func orientedJPEG(t *testing.T, orient int) []byte {
|
||||
t.Helper()
|
||||
const w, h = 40, 24
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
c := color.RGBA{0, 0, 0, 255}
|
||||
if x < w/2 && y < h/2 {
|
||||
c = color.RGBA{255, 255, 255, 255}
|
||||
}
|
||||
m.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var jb bytes.Buffer
|
||||
if err := jpeg.Encode(&jb, m, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
if orient == 0 {
|
||||
return jb.Bytes() // caller wants a plain JPEG with no EXIF
|
||||
}
|
||||
return spliceExifOrientation(t, jb.Bytes(), orient)
|
||||
}
|
||||
|
||||
// spliceExifOrientation inserts a minimal little-endian Exif APP1 segment
|
||||
// carrying just the Orientation tag right after the JPEG SOI marker.
|
||||
func spliceExifOrientation(t *testing.T, jpg []byte, orient int) []byte {
|
||||
t.Helper()
|
||||
var tiff bytes.Buffer
|
||||
tiff.WriteString("II") // little-endian
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x2A))
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(8)) // IFD0 offset
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(1)) // one entry
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x0112))
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(3)) // SHORT
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(1)) // count
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(orient)) // value
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0)) // value pad
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(0)) // next IFD
|
||||
payload := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
|
||||
|
||||
segLen := len(payload) + 2
|
||||
seg := []byte{0xFF, 0xE1, byte(segLen >> 8), byte(segLen)}
|
||||
seg = append(seg, payload...)
|
||||
|
||||
out := make([]byte, 0, len(jpg)+len(seg))
|
||||
out = append(out, jpg[:2]...) // SOI
|
||||
out = append(out, seg...)
|
||||
return append(out, jpg[2:]...)
|
||||
}
|
||||
|
||||
func bright(c color.Color) bool {
|
||||
r, g, b, _ := c.RGBA() // 16-bit
|
||||
return (r+g+b)/3 > 0x8000
|
||||
}
|
||||
|
||||
// TestNormalizeAppliesExifOrientation is the #103 regression: a phone photo tagged
|
||||
// "rotate 90°" must come out of Normalize with the pixels upright, not sideways —
|
||||
// the re-encode strips EXIF, so the rotation has to be baked into the bitmap.
|
||||
func TestNormalizeAppliesExifOrientation(t *testing.T) {
|
||||
// The white marker starts centred at (10,6) in the 40x24 source. For each
|
||||
// orientation, wantW/H is the corrected canvas and (mx,my) is where that
|
||||
// marker must land — derived from the same transform Normalize applies.
|
||||
cases := []struct {
|
||||
name string
|
||||
orient int
|
||||
wantW, wantH int
|
||||
mx, my int
|
||||
}{
|
||||
{"none", 0, 40, 24, 10, 6}, // no EXIF → unchanged, marker top-left
|
||||
{"normal", 1, 40, 24, 10, 6}, // normal → unchanged
|
||||
{"mirror-h", 2, 40, 24, 29, 6}, // flip horizontal → top-right
|
||||
{"rotate-180", 3, 40, 24, 29, 17}, // → bottom-right
|
||||
{"mirror-v", 4, 40, 24, 10, 17}, // flip vertical → bottom-left
|
||||
{"transpose", 5, 24, 40, 6, 10}, // main diagonal (dims swap)
|
||||
{"rotate-90-cw", 6, 24, 40, 17, 10}, // → top-right (dims swap)
|
||||
{"transverse", 7, 24, 40, 17, 29}, // anti-diagonal (dims swap)
|
||||
{"rotate-90-ccw", 8, 24, 40, 6, 29}, // → bottom-left (dims swap)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, format, err := Normalize(bytes.NewReader(orientedJPEG(t, tc.orient)), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize err = %v", err)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Errorf("format = %q, want jpeg", format)
|
||||
}
|
||||
m, _, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode output: %v", err)
|
||||
}
|
||||
if m.Bounds().Dx() != tc.wantW || m.Bounds().Dy() != tc.wantH {
|
||||
t.Errorf("output %dx%d, want %dx%d",
|
||||
m.Bounds().Dx(), m.Bounds().Dy(), tc.wantW, tc.wantH)
|
||||
}
|
||||
if !bright(m.At(m.Bounds().Min.X+tc.mx, m.Bounds().Min.Y+tc.my)) {
|
||||
t.Errorf("white marker not at (%d,%d) — orientation not applied", tc.mx, tc.my)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExifOrientationParsing pins the parser against non-JPEG and no-EXIF inputs,
|
||||
// which must default to 1 (never guess a rotation onto a correct image).
|
||||
func TestExifOrientationParsing(t *testing.T) {
|
||||
if o := exifOrientation(pngBytes(t, 8, 8)); o != 1 {
|
||||
t.Errorf("PNG orientation = %d, want 1 (no JPEG EXIF path)", o)
|
||||
}
|
||||
if o := exifOrientation(orientedJPEG(t, 0)); o != 1 {
|
||||
t.Errorf("JPEG without EXIF orientation = %d, want 1", o)
|
||||
}
|
||||
if o := exifOrientation(orientedJPEG(t, 6)); o != 6 {
|
||||
t.Errorf("JPEG tagged 6 → %d, want 6", o)
|
||||
}
|
||||
if o := exifOrientation([]byte("not an image")); o != 1 {
|
||||
t.Errorf("garbage bytes → %d, want 1", o)
|
||||
}
|
||||
}
|
||||
|
||||
+115
-44
@@ -97,30 +97,106 @@ func defaultPlopRadius(spacingCM float64) float64 {
|
||||
return math.Max(1.5*spacingCM, 15)
|
||||
}
|
||||
|
||||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
||||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
||||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
||||
// radius pitch, centered in the region, and set in from each edge by the plop's
|
||||
// radius less half a spacing — see hexCenters for why that half-spacing is what
|
||||
// the edge is owed. 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) ([]domain.Planting, error) {
|
||||
// FillLayout selects what a fill packs (#77).
|
||||
//
|
||||
// A plop is a CLUMP, not a plant, and that abstraction is the right primitive for
|
||||
// SKETCHING — "a few plops of garlic in one corner" — but it can't draw a real
|
||||
// planting: a filled 4×8ft bed comes out as ~15 blobs, not 8 rows of garlic. So
|
||||
// filling is now two operations. FillClump (the default, unchanged) drops fat
|
||||
// clumps for quick coverage; FillGrid lays out individual plants at true spacing,
|
||||
// producing a layout you could actually plant from.
|
||||
type FillLayout string
|
||||
|
||||
const (
|
||||
// FillClump packs fat clumps (radius 1.5×spacing). Each plop is ~7 plants.
|
||||
FillClump FillLayout = "clump"
|
||||
// FillGrid packs one plant per plop at true spacing (radius spacing/2, pitch
|
||||
// = spacing). A bed becomes rows of individual plants.
|
||||
FillGrid FillLayout = "grid"
|
||||
)
|
||||
|
||||
// plopRadiusFor is the plop radius a fill uses, given the plant's spacing and the
|
||||
// layout. Grid mode is a plain spacing/2 (so the pitch is one spacing and each
|
||||
// plop's derived count is 1); clump mode keeps the 15cm floor that stops a
|
||||
// tiny-spacing plant from making invisibly small clumps — a floor grid mode
|
||||
// doesn't want, since its whole point is true spacing.
|
||||
func plopRadiusFor(spacingCM float64, layout FillLayout) float64 {
|
||||
if layout == FillGrid {
|
||||
return spacingCM / 2
|
||||
}
|
||||
return defaultPlopRadius(spacingCM)
|
||||
}
|
||||
|
||||
// edgeInset is how far a plop's CENTRE must stay inside the region edge. It
|
||||
// differs by layout because the half-spacing rule is about where the PLANT lands,
|
||||
// and the plant sits in a different place within the plop.
|
||||
//
|
||||
// Spacing is a constraint between neighbouring plants competing for the same soil,
|
||||
// light and water; a bed edge is nobody's neighbour, so the outer plant owes it
|
||||
// only HALF the spacing — the half it would otherwise share. That is the
|
||||
// square-foot-chart arithmetic: garlic at 9-per-square sits 2" from the frame, not
|
||||
// 6".
|
||||
//
|
||||
// - Grid: one plant, at the plop's centre. Put that centre a half-spacing in and
|
||||
// the outer row lands exactly where the rule wants it — inset = spacing/2.
|
||||
// - Clump: a fat plop (radius 1.5×spacing) whose plants fill out to its RIM.
|
||||
// Insetting the whole circle would push the outer row a full 1.5 spacings in,
|
||||
// three times the rule. Instead the clump may hang over by a half-spacing (rim
|
||||
// at spacing/2 past the edge), landing its outermost plants that same
|
||||
// half-spacing in — inset = radius − spacing/2. A grid plop reusing THAT
|
||||
// formula would inset by radius − spacing/2 = 0 and plant flush on the edge,
|
||||
// which is the bug this split fixes.
|
||||
func edgeInset(radius, spacing float64, layout FillLayout) float64 {
|
||||
half := math.Max(0, spacing) / 2
|
||||
if layout == FillGrid {
|
||||
return half
|
||||
}
|
||||
return math.Max(0, radius-half)
|
||||
}
|
||||
|
||||
// validFillLayout normalizes a layout: empty defaults to clump (so existing
|
||||
// callers are unchanged), a known value passes, anything else is rejected.
|
||||
func validFillLayout(l FillLayout) (FillLayout, bool) {
|
||||
switch l {
|
||||
case "", FillClump:
|
||||
return FillClump, true
|
||||
case FillGrid:
|
||||
return FillGrid, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// FillRegion lays a field of plops of one plant across a region of a plantable
|
||||
// object the actor can edit. The layout picks the primitive: FillClump drops fat
|
||||
// clumps for quick sketching, FillGrid lays out individual plants at true spacing
|
||||
// (see FillLayout). Plop radius comes from the plant's spacing (or spacingOverride)
|
||||
// via plopRadiusFor; centers sit on a centered hex lattice at 2×radius pitch, set
|
||||
// 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) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
|
||||
}
|
||||
|
||||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||||
// already loaded and authorized (roleEditor). It rejects a 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) ([]domain.Planting, error) {
|
||||
// already loaded and authorized (roleEditor). It validates the layout, rejects a
|
||||
// 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) {
|
||||
if !o.Plantable {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
layout, ok := validFillLayout(layout)
|
||||
if !ok {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
plant, err := s.visiblePlant(ctx, actorID, plantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -132,7 +208,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
}
|
||||
spacing = *spacingOverride
|
||||
}
|
||||
radius := defaultPlopRadius(spacing)
|
||||
radius := plopRadiusFor(spacing, layout)
|
||||
if !isFinite(radius) || radius <= 0 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
@@ -150,7 +226,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
}
|
||||
|
||||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||||
centers, total := hexCenters(region, radius, spacing, maxFillPlops)
|
||||
centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops)
|
||||
if total > maxFillPlops {
|
||||
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
||||
}
|
||||
@@ -161,13 +237,17 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
}
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
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
|
||||
// is "covered" only when it lies entirely inside another — impossible between
|
||||
// two equal-radius circles at different centres. So skip against `existing` as
|
||||
// loaded and don't grow it per plop, which made an empty-bed grid fill's check
|
||||
// needlessly quadratic.
|
||||
for _, c := range centers {
|
||||
if coveredByExisting(c.x, c.y, radius, existing) {
|
||||
continue
|
||||
}
|
||||
p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
|
||||
batch = append(batch, p)
|
||||
existing = append(existing, *p) // so later candidates in THIS fill don't stack on it
|
||||
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today})
|
||||
}
|
||||
created, err := s.store.CreatePlantings(ctx, batch)
|
||||
if err != nil {
|
||||
@@ -196,24 +276,14 @@ type localPoint struct{ x, y float64 }
|
||||
//
|
||||
// # How close to the edge the outer row goes
|
||||
//
|
||||
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
|
||||
// soil, light and water. A bed edge is not a competitor, so the outer row only
|
||||
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
|
||||
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
|
||||
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
|
||||
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
|
||||
// The caller passes `inset`: the margin the outer row keeps from every edge. It
|
||||
// encodes the half-spacing rule (spacing is owed between neighbouring plants, and
|
||||
// a bed edge is nobody's neighbour) and differs by layout — see edgeInset, which
|
||||
// derives it. hexCenters just honours it on all four sides.
|
||||
//
|
||||
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so
|
||||
// three spacings across — and its plants sit out to its rim. So keeping the whole
|
||||
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
|
||||
// times what the rule allows. Instead the clump may hang over the edge by up to
|
||||
// half a spacing, which puts its outermost plants exactly the half-spacing from
|
||||
// the edge that the rule asks for. Overhang is capped there and nowhere near the
|
||||
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
|
||||
//
|
||||
// Do not "simplify" this back to anchoring at the region's min corner. That is
|
||||
// what #75 was: staggered rows start a full pitch in, and the leftover all lands
|
||||
// on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||||
// Do not "simplify" the centering back to anchoring at the region's min corner.
|
||||
// That is what #75 was: staggered rows start a full pitch in, and the leftover all
|
||||
// lands on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||||
//
|
||||
// # Counting before building
|
||||
//
|
||||
@@ -221,7 +291,7 @@ type localPoint struct{ x, y float64 }
|
||||
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
||||
// its whole lattice first just to be counted and thrown away. Over `limit` it
|
||||
// returns (nil, total), so the caller can still refuse with the real number.
|
||||
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
|
||||
func hexCenters(r Region, radius, inset float64, limit int) ([]localPoint, int) {
|
||||
if radius <= 0 {
|
||||
return nil, 0
|
||||
}
|
||||
@@ -234,11 +304,6 @@ func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int
|
||||
pitch := 2 * radius
|
||||
rowH := pitch * math.Sqrt(3) / 2
|
||||
|
||||
// How far a clump's centre must stay inside the edge: its own radius, less the
|
||||
// half-spacing of overhang the rule allows. Never negative, and never past the
|
||||
// centre of the clump.
|
||||
inset := math.Max(0, radius-math.Max(0, spacing)/2)
|
||||
|
||||
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
|
||||
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
|
||||
|
||||
@@ -311,7 +376,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) ([]domain.Planting, error) {
|
||||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -320,7 +385,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)
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
|
||||
}
|
||||
|
||||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||||
@@ -406,7 +471,11 @@ type DescribeObject struct {
|
||||
}
|
||||
|
||||
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
|
||||
// ID + Version are included so an agent can address a single plop — remove it or
|
||||
// move it — the same way DescribeObject.Version lets it edit an object.
|
||||
type DescribePlanting struct {
|
||||
ID int64 `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
PlantID int64 `json:"plantId"`
|
||||
Plant string `json:"plant"`
|
||||
Count int `json:"count"`
|
||||
@@ -453,6 +522,8 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
|
||||
count = *pl.Count
|
||||
}
|
||||
do.Plantings = append(do.Plantings, DescribePlanting{
|
||||
ID: pl.ID,
|
||||
Version: pl.Version,
|
||||
PlantID: pl.PlantID,
|
||||
Plant: plantByID[pl.PlantID].Name,
|
||||
Count: count,
|
||||
|
||||
@@ -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); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestHexCentersEdgeInset(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
|
||||
pts, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops)
|
||||
pts, total := hexCenters(r, tc.radius, edgeInset(tc.radius, tc.spacing, FillClump), maxFillPlops)
|
||||
if len(pts) == 0 {
|
||||
t.Fatal("no centers")
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func TestHexCentersTinyRegion(t *testing.T) {
|
||||
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pts, _ := hexCenters(tc.r, 15, 10, maxFillPlops)
|
||||
pts, _ := hexCenters(tc.r, 15, edgeInset(15, 10, FillClump), maxFillPlops)
|
||||
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
|
||||
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
|
||||
}
|
||||
@@ -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)
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump)
|
||||
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)
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump)
|
||||
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)
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
||||
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)
|
||||
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
||||
if err != nil {
|
||||
t.Fatalf("second FillRegion: %v", err)
|
||||
}
|
||||
@@ -292,6 +292,70 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillGridLaysOutIndividualPlants is the #77 grid mode: a grid fill packs one
|
||||
// plant per plop at true spacing, so a bed becomes rows of plants rather than a
|
||||
// few fat clumps. On the same bed it produces many more, smaller plops, each a
|
||||
// single plant.
|
||||
func TestFillGridLaysOutIndividualPlants(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||
bed := seedFillBed(t, s, owner, g.ID, 60, 60)
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("grid: %v", err)
|
||||
}
|
||||
|
||||
// Grid packs at spacing 10 (radius 5, pitch 10); clump at radius 15 (pitch 30).
|
||||
// Grid must produce many more plops.
|
||||
if len(grid) <= len(clump) {
|
||||
t.Errorf("grid produced %d plops, clump %d — grid should be denser", len(grid), len(clump))
|
||||
}
|
||||
// Each grid plop is one plant at radius spacing/2 = 5.
|
||||
maxAbs := 0.0
|
||||
for _, p := range grid {
|
||||
if p.RadiusCM != 5 {
|
||||
t.Errorf("grid plop radius = %v, want 5 (spacing/2)", p.RadiusCM)
|
||||
}
|
||||
if p.DerivedCount != 1 {
|
||||
t.Errorf("grid plop derived count = %d, want 1 (one plant per plop)", p.DerivedCount)
|
||||
}
|
||||
maxAbs = math.Max(maxAbs, math.Max(math.Abs(p.XCM), math.Abs(p.YCM)))
|
||||
}
|
||||
// The half-spacing edge rule: a grid plant sits AT the plop centre, so the
|
||||
// outer row is inset a half-spacing (spacing/2 = 5) — at ±25 on this 60cm bed,
|
||||
// not flush on ±30. Regression guard: the clump inset formula (radius − half)
|
||||
// collapses to 0 for grid and would plant one on the very edge.
|
||||
if edge := 30.0; maxAbs > edge-5+1e-6 {
|
||||
t.Errorf("outermost grid plop at |coord|=%.2f — only %.2fcm from the edge; want a half-spacing (5cm) in", maxAbs, edge-maxAbs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRejectsUnknownLayout: a layout that isn't clump/grid is ErrInvalidInput.
|
||||
func TestFillRejectsUnknownLayout(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||
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) {
|
||||
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
@@ -304,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)
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
|
||||
if err != nil {
|
||||
t.Fatalf("FillRegion: %v", err)
|
||||
}
|
||||
@@ -327,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); err != nil {
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
|
||||
@@ -361,7 +425,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
|
||||
}
|
||||
region, _ := NamedRegion(bed, "all")
|
||||
|
||||
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrForbidden) {
|
||||
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump); !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) {
|
||||
@@ -391,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); err != nil {
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump); err != nil {
|
||||
t.Fatalf("fill %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,17 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// RemovePlanting soft-removes a single plop — the one-plop counterpart to
|
||||
// ClearObject, used by the agent's remove_planting tool. It stamps removed_at
|
||||
// from the service clock (s.now()), same as ClearObject and the fill path, so the
|
||||
// removal date can't diverge by which caller set it; then delegates to
|
||||
// UpdatePlanting for the editor-role check, version guard and history record.
|
||||
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) {
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
return s.UpdatePlanting(ctx, actorID, plantingID,
|
||||
PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version)
|
||||
}
|
||||
|
||||
// plantingEditSummary describes a plop edit for the history list. Soft-removal
|
||||
// ("clear bed", harvested) is the one edit worth naming specifically — it reads
|
||||
// as a removal to the person who did it, not as an edit.
|
||||
|
||||
@@ -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)
|
||||
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump)
|
||||
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); err != nil {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); 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); err != nil {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); 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); err != nil {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); 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); err != nil {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
||||
return err
|
||||
}
|
||||
cancel() // the client disconnects, mid-turn, after the work landed
|
||||
|
||||
Generated
+1469
-5
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.14"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Placeholder page scaffolding used until each feature issue fills these in. */
|
||||
export function PageStub({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
{children ?? 'Placeholder — this page arrives in a later issue.'}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useDeleteGarden, type Garden } from '@/lib/gardens'
|
||||
|
||||
/** Confirmation dialog for deleting a garden (and everything in it). */
|
||||
export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const deletion = useDeleteGarden()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onConfirm() {
|
||||
setError(null)
|
||||
try {
|
||||
await deletion.mutateAsync(garden.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not delete the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Delete garden" onClose={onClose} busy={deletion.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
|
||||
This can't be undone.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}>
|
||||
{deletion.isPending ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
title="Delete garden"
|
||||
confirmLabel="Delete"
|
||||
busyLabel="Deleting…"
|
||||
errorFallback="Could not delete the garden."
|
||||
onConfirm={() => deletion.mutateAsync(garden.id)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
|
||||
This can't be undone.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { useRemoveShare } from '@/lib/shares'
|
||||
@@ -12,36 +8,26 @@ import { useRemoveShare } from '@/lib/shares'
|
||||
export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const me = useMe()
|
||||
const remove = useRemoveShare(garden.id)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onConfirm() {
|
||||
if (!me.data) return
|
||||
setError(null)
|
||||
try {
|
||||
await remove.mutateAsync(me.data.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not leave the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Leave garden" onClose={onClose} busy={remove.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
|
||||
shares it with you again.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={remove.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={onConfirm} disabled={remove.isPending || !me.data}>
|
||||
{remove.isPending ? 'Leaving…' : 'Leave'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
title="Leave garden"
|
||||
confirmLabel="Leave"
|
||||
busyLabel="Leaving…"
|
||||
confirmDisabled={!me.data}
|
||||
errorFallback="Could not leave the garden."
|
||||
onConfirm={async () => {
|
||||
// The button is disabled without a current user; throw rather than
|
||||
// silently resolve (which would close the dialog as if it had worked) if
|
||||
// that guard ever drifts.
|
||||
if (!me.data) throw new Error('Not signed in.')
|
||||
await remove.mutateAsync(me.data.id)
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
|
||||
shares it with you again.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
import { useLogout, useMe } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/gardens', label: 'Gardens' },
|
||||
{ to: '/plants', label: 'Plants' },
|
||||
// Top-level sections. `icon` is only used by the mobile bottom bar; the desktop
|
||||
// top bar shows labels alone. Settings is filtered to admins at render.
|
||||
const sections = [
|
||||
{ to: '/gardens', label: 'Gardens', icon: '🏡', adminOnly: false },
|
||||
{ to: '/plants', label: 'Plants', icon: '🌿', adminOnly: false },
|
||||
{ to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true },
|
||||
] as const
|
||||
|
||||
type Section = (typeof sections)[number]
|
||||
|
||||
// TanStack Router concatenates the base className with activeProps/inactiveProps,
|
||||
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
|
||||
// state props — never in the base — to avoid ambiguous overrides.
|
||||
@@ -14,73 +21,74 @@ const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-color
|
||||
const navLinkActive = 'bg-border/60 text-fg'
|
||||
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
|
||||
|
||||
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
|
||||
/**
|
||||
* Top-level chrome. Mobile-first: a slim top bar (brand + account) with the
|
||||
* section nav moved to a thumb-reachable bottom tab bar; the desktop breakpoint
|
||||
* (`md:`) restores the inline top nav. The editor is a full-screen context, so it
|
||||
* owns the bottom of the screen — the app bottom bar hides there (the brand link
|
||||
* is the way back to the gardens list), leaving exactly one bottom bar per route.
|
||||
*/
|
||||
export function AppShell() {
|
||||
const me = useMe()
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const user = me.data
|
||||
const matchRoute = useMatchRoute()
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await logout.mutateAsync()
|
||||
await navigate({ to: '/login' })
|
||||
} catch {
|
||||
// The logout request failed, so the session is still valid server-side:
|
||||
// leave the user where they are (the button re-enables for a retry) rather
|
||||
// than pretending they're signed out. logout.isError drives the title below.
|
||||
}
|
||||
}
|
||||
// Full-screen canvas contexts own the bottom of the screen: the garden editor
|
||||
// and the public shared-garden view both render a 100dvh-8rem canvas, so a
|
||||
// signed-in viewer must not get the app bottom bar overlapping it. Fuzzy:false
|
||||
// so the '/gardens' list itself still shows the bar.
|
||||
const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false })
|
||||
const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false })
|
||||
// A canvas route wants the whole width — the garden editor is squeezed by the
|
||||
// max-w-5xl reading measure the other pages use (#107).
|
||||
const canvasRoute = inEditor || inPublicGarden
|
||||
const showBottomNav = !!user && !canvasRoute
|
||||
// On a phone the editor is a full-screen canvas, so the global top bar is pure
|
||||
// chrome above the garden — hide it and let the editor's own strip carry the
|
||||
// back link AND the account menu (so sign-out isn't lost). Editor only, not the
|
||||
// public view, which has no strip of its own to fall back on.
|
||||
const hideHeaderOnMobile = inEditor
|
||||
|
||||
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
|
||||
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur',
|
||||
hideHeaderOnMobile && 'hidden md:block',
|
||||
)}
|
||||
>
|
||||
{/* The bar matches the content width below: constrained on reading pages,
|
||||
edge-to-edge on the canvas routes so the brand aligns with the editor. */}
|
||||
<nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}>
|
||||
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
||||
🌱 pansy
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
{/* Desktop inline section links. Hidden on mobile, where the bottom bar
|
||||
carries them. */}
|
||||
<div className="hidden flex-1 items-center gap-1 md:flex">
|
||||
{user &&
|
||||
navLinks.map((l) => (
|
||||
visibleSections.map((s) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
key={s.to}
|
||||
to={s.to}
|
||||
className={navLinkBase}
|
||||
activeProps={{ className: navLinkActive }}
|
||||
inactiveProps={{ className: navLinkInactive }}
|
||||
>
|
||||
{l.label}
|
||||
{s.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* Settings is admin-only, matching the server's requireAdmin gate.
|
||||
A non-admin who typed /settings still gets a 403 from the API — the
|
||||
hidden link is convenience, not the security boundary. */}
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
to="/settings"
|
||||
className={navLinkBase}
|
||||
activeProps={{ className: navLinkActive }}
|
||||
inactiveProps={{ className: navLinkInactive }}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spacer so account/sign-out sits right on mobile (the desktop links
|
||||
own flex-1 above). */}
|
||||
<div className="flex-1 md:hidden" />
|
||||
|
||||
{user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={logout.isPending}
|
||||
title={logout.isError ? 'Sign out failed — try again' : undefined}
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg disabled:opacity-60"
|
||||
>
|
||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
<AccountMenu displayName={user.displayName} />
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
@@ -92,11 +100,137 @@ export function AppShell() {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
|
||||
<Outlet />
|
||||
<main
|
||||
className={cn(
|
||||
'w-full flex-1 px-4 py-6',
|
||||
// Constrain the reading pages to a comfortable measure; the canvas
|
||||
// routes go edge-to-edge so the garden gets the whole screen.
|
||||
!canvasRoute && 'mx-auto max-w-5xl',
|
||||
// Clear the fixed bottom bar on mobile so content isn't hidden behind
|
||||
// it. The 3.5rem must match BottomNav's h-14 (kept adjacent below).
|
||||
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
|
||||
)}
|
||||
>
|
||||
{/* Boundary for the lazily-loaded routes (see router.tsx). */}
|
||||
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading…</p>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
{showBottomNav && <BottomNav sections={visibleSections} />}
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Account control: a compact button that toggles a small sign-out popover. On
|
||||
* desktop the display name shows inline; on mobile it lives inside the popover.
|
||||
* Exported so the editor's mobile strip can carry it — the global header that
|
||||
* normally hosts it is hidden there (see hideHeaderOnMobile). */
|
||||
export function AccountMenu({ displayName }: { displayName: string }) {
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
// Close on any route change, so navigating (bottom nav, browser back) can't
|
||||
// leave the popover — and its full-screen backdrop — stuck open over the page.
|
||||
useEffect(() => setOpen(false), [pathname])
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await logout.mutateAsync()
|
||||
await navigate({ to: '/login' })
|
||||
} catch {
|
||||
// The logout request failed, so the session is still valid server-side.
|
||||
// Keep the popover OPEN so the button (now "Retry sign out", driven by
|
||||
// logout.isError) stays on screen — closing it would hide the only retry
|
||||
// affordance and pretend nothing went wrong.
|
||||
}
|
||||
}
|
||||
|
||||
const initial = displayName.trim().charAt(0).toUpperCase() || '·'
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
<span className="hidden sm:inline">{displayName}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 items-center justify-center rounded-full bg-border/60 text-sm font-semibold text-fg"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{/* Full-screen click-away backdrop; reliably closes on an outside tap
|
||||
without a document listener. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-30 cursor-default"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-40 mt-2 w-48 rounded-lg border border-border bg-surface p-1 shadow-lg"
|
||||
>
|
||||
<p className="truncate px-3 py-2 text-xs text-muted">
|
||||
Signed in as <span className="text-fg">{displayName}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onLogout}
|
||||
disabled={logout.isPending}
|
||||
title={logout.isError ? 'Sign out failed — try again' : undefined}
|
||||
className="w-full rounded-md px-3 py-2 text-left text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg disabled:opacity-60"
|
||||
>
|
||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */
|
||||
function BottomNav({ sections }: { sections: ReadonlyArray<Section> }) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-surface/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||
>
|
||||
<ul className="mx-auto flex max-w-5xl items-stretch justify-around">
|
||||
{sections.map((s) => (
|
||||
<li key={s.to} className="flex-1">
|
||||
{/* h-14 matches the clearance reserved on <main> above. Color lives
|
||||
only in the state props (per the top-bar convention), never the
|
||||
base, so active/inactive don't fight. */}
|
||||
<Link
|
||||
to={s.to}
|
||||
className="flex h-14 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors"
|
||||
activeProps={{ className: 'text-accent-strong' }}
|
||||
inactiveProps={{ className: 'text-muted hover:text-fg' }}
|
||||
>
|
||||
<span aria-hidden className="text-lg leading-none">
|
||||
{s.icon}
|
||||
</span>
|
||||
{s.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,26 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useDeletePlant, type Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* Confirmation dialog for deleting a custom plant. A plant still used by
|
||||
* plantings is refused by the server (409 PLANT_IN_USE); we surface that
|
||||
* message inline rather than pretending it worked.
|
||||
* plantings is refused by the server (409 PLANT_IN_USE); ConfirmModal surfaces
|
||||
* that message inline rather than pretending it worked.
|
||||
*/
|
||||
export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) {
|
||||
const deletion = useDeletePlant()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onConfirm() {
|
||||
setError(null)
|
||||
try {
|
||||
await deletion.mutateAsync(plant.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not delete the plant.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Delete plant" onClose={onClose} busy={deletion.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
|
||||
undone.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}>
|
||||
{deletion.isPending ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
title="Delete plant"
|
||||
confirmLabel="Delete"
|
||||
busyLabel="Deleting…"
|
||||
errorFallback="Could not delete the plant."
|
||||
onConfirm={() => deletion.mutateAsync(plant.id)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
|
||||
undone.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||
|
||||
/**
|
||||
@@ -12,39 +8,23 @@ import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||
*/
|
||||
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
|
||||
const del = useDeleteSeedLot()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-fg">
|
||||
{formatQuantity(lot.quantity)} {lot.unit}
|
||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={del.isPending}
|
||||
onClick={() =>
|
||||
del.mutate(lot.id, {
|
||||
onSuccess: onClose,
|
||||
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
|
||||
})
|
||||
}
|
||||
>
|
||||
{del.isPending ? 'Retiring…' : 'Retire lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
title="Retire this seed lot?"
|
||||
confirmLabel="Retire lot"
|
||||
busyLabel="Retiring…"
|
||||
errorFallback="Could not retire the lot."
|
||||
onConfirm={() => del.mutateAsync(lot.id)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-fg">
|
||||
{formatQuantity(lot.quantity)} {lot.unit}
|
||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function PlantCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLots((v) => !v)}
|
||||
className={`${cardActionClass} mr-auto flex items-center gap-1.5`}
|
||||
className={`${cardActionClass} mr-auto gap-1.5`}
|
||||
aria-expanded={showLots}
|
||||
>
|
||||
{lots.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* A small tap-to-arm plant chip (icon + name), highlighted when it's the armed
|
||||
* plant. Shared by the Seed Tray (which wraps it with a remove button) and the
|
||||
* Recent-plants strip, so the two quick-pick surfaces stay visually identical.
|
||||
* `rounded` is false when a caller (the tray) attaches a trailing control and
|
||||
* needs a flat right edge.
|
||||
*/
|
||||
export function PlantChip({
|
||||
plant,
|
||||
active,
|
||||
onArm,
|
||||
rounded = true,
|
||||
}: {
|
||||
plant: Plant
|
||||
active: boolean
|
||||
onArm: (plant: Plant) => void
|
||||
rounded?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArm(plant)}
|
||||
aria-pressed={active}
|
||||
title={active ? `Placing ${plant.name}` : `Place ${plant.name}`}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
rounded ? 'rounded-full pr-2.5' : 'rounded-l-full pr-1',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent-strong'
|
||||
: 'border-border bg-surface text-fg hover:border-accent',
|
||||
)}
|
||||
>
|
||||
<PlantIcon color={plant.color} icon={plant.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
|
||||
<span className="max-w-[6rem] truncate">{plant.name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
lotDefaults,
|
||||
newPlantDefaults,
|
||||
useCreateFromPacket,
|
||||
useScanPacket,
|
||||
type PacketProposal,
|
||||
} from '@/lib/seedPacket'
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
PLANT_CATEGORIES,
|
||||
type PlantCategory,
|
||||
type PlantInput,
|
||||
} from '@/lib/plants'
|
||||
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
|
||||
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||
|
||||
// 'new' is "create a new variety"; a number selects that existing candidate plant.
|
||||
type Selection = number | 'new'
|
||||
|
||||
/**
|
||||
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
|
||||
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
|
||||
* review never commits blind — the model can misread, so every committed field is
|
||||
* editable and the human picks "this is an existing plant" vs "a new variety".
|
||||
*
|
||||
* Only offered where `capabilities.vision` is on (the caller gates the entry
|
||||
* point), so a scan should always be possible; a 503 is still handled in case the
|
||||
* model is torn down between the capabilities poll and the upload.
|
||||
*/
|
||||
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
|
||||
const scan = useScanPacket()
|
||||
const create = useCreateFromPacket()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
|
||||
// dialog is never a trap the user can only escape by reloading the page.
|
||||
const scanAbort = useRef<AbortController | null>(null)
|
||||
|
||||
const [proposal, setProposal] = useState<PacketProposal | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Review-phase fields, seeded from the proposal when a scan lands.
|
||||
const [selection, setSelection] = useState<Selection>('new')
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState<PlantCategory>('vegetable')
|
||||
const [spacing, setSpacing] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
|
||||
// creating one) and the lot.
|
||||
const [vendor, setVendor] = useState('')
|
||||
const [quantity, setQuantity] = useState('')
|
||||
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
|
||||
const [sku, setSku] = useState('')
|
||||
const [lotCode, setLotCode] = useState('')
|
||||
const [packedForYear, setPackedForYear] = useState('')
|
||||
const [cost, setCost] = useState('')
|
||||
|
||||
const unitLabel = spacingUnitLabel(unit)
|
||||
const busy = scan.isPending || create.isPending
|
||||
|
||||
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
// Reset the input so re-picking the same file fires change again (e.g. after
|
||||
// an error, retrying the same photo).
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setError(null)
|
||||
const controller = new AbortController()
|
||||
scanAbort.current = controller
|
||||
scan.mutate(
|
||||
{ file, signal: controller.signal },
|
||||
{
|
||||
onSuccess: (p) => {
|
||||
const plant = newPlantDefaults(p)
|
||||
const lot = lotDefaults(p.packet)
|
||||
setProposal(p)
|
||||
// Default to the top candidate when there is one — the likely case is
|
||||
// the packet is a variety already in the catalog — else create a new one.
|
||||
setSelection(p.candidates[0]?.plant.id ?? 'new')
|
||||
setName(plant.name)
|
||||
setCategory(plant.category)
|
||||
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
|
||||
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
|
||||
setVendor(lot.vendor)
|
||||
setQuantity(String(lot.quantity))
|
||||
setLotUnit(lot.unit)
|
||||
setSku(lot.sku)
|
||||
setLotCode(lot.lotCode)
|
||||
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
// Cost isn't on a packet, so it's the one field not reseeded from the
|
||||
// proposal; clear it so a value typed before a Rescan doesn't linger.
|
||||
setCost('')
|
||||
},
|
||||
onError: (err) => {
|
||||
// An aborted scan is a user cancel, not a failure — and Cancel also
|
||||
// closes the dialog, so there's nothing to report.
|
||||
if ((err as Error)?.name === 'AbortError') return
|
||||
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onConfirm(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!proposal) return
|
||||
setError(null)
|
||||
|
||||
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) {
|
||||
setError('Quantity must be a number, or left blank.')
|
||||
return
|
||||
}
|
||||
let year: number | null = null
|
||||
if (packedForYear.trim()) {
|
||||
const y = Number(packedForYear)
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
|
||||
setError('Packed-for year should be a four-digit year.')
|
||||
return
|
||||
}
|
||||
year = y
|
||||
}
|
||||
let costCents: number | null = null
|
||||
if (cost.trim()) {
|
||||
const c = Number(cost)
|
||||
if (!Number.isFinite(c) || c < 0) {
|
||||
setError('Cost must be an amount, or left blank.')
|
||||
return
|
||||
}
|
||||
costCents = Math.round(c * 100)
|
||||
}
|
||||
|
||||
const lot = {
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: '',
|
||||
sku: sku.trim(),
|
||||
lotCode: lotCode.trim(),
|
||||
purchasedAt: null,
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit: lotUnit,
|
||||
costCents,
|
||||
germinationPct: null,
|
||||
notes: '',
|
||||
}
|
||||
|
||||
let newPlant: PlantInput | undefined
|
||||
let plantId: number | undefined
|
||||
if (selection === 'new') {
|
||||
if (!name.trim()) {
|
||||
setError('Name the new variety, or pick an existing plant above.')
|
||||
return
|
||||
}
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
|
||||
setError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||
return
|
||||
}
|
||||
let daysToMaturity: number | null = null
|
||||
if (days.trim()) {
|
||||
const d = Number(days)
|
||||
if (!Number.isInteger(d) || d < 1) {
|
||||
setError('Days to maturity must be a whole number of days, or left blank.')
|
||||
return
|
||||
}
|
||||
daysToMaturity = d
|
||||
}
|
||||
newPlant = {
|
||||
...newPlantDefaults(proposal),
|
||||
name: name.trim(),
|
||||
category,
|
||||
spacingCm,
|
||||
daysToMaturity,
|
||||
vendor: vendor.trim(),
|
||||
}
|
||||
} else {
|
||||
plantId = selection
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await create.mutateAsync({ plantId, newPlant, lot })
|
||||
toast.info(
|
||||
res.plantIsNew
|
||||
? `Added ${res.plant.name} and its seed lot.`
|
||||
: `Recorded a seed lot for ${res.plant.name}.`,
|
||||
)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not save the packet.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
|
||||
{!proposal ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Take a photo of the front of a seed packet and pansy reads the details off it — you review and
|
||||
confirm before anything is saved.
|
||||
</p>
|
||||
|
||||
{/* A hidden input is triggered by the buttons below. `capture` hints a
|
||||
phone to open the camera; on desktop it's ignored and both buttons
|
||||
open a file chooser. */}
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={onFile}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{scan.isPending ? (
|
||||
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
Reading the packet… this can take a few seconds.
|
||||
</p>
|
||||
) : (
|
||||
<Button type="button" onClick={() => fileInput.current?.click()}>
|
||||
Take or choose a photo
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
{/* Not disabled while scanning — this is the way out of a slow scan.
|
||||
Aborting a settled/absent request is a harmless no-op. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
scanAbort.current?.abort()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onConfirm} className="flex flex-col gap-4">
|
||||
<ReadFields proposal={proposal} unit={unit} />
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium text-fg">This packet is…</legend>
|
||||
{proposal.candidates.map((c) => (
|
||||
<label
|
||||
key={c.plant.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="packet-selection"
|
||||
checked={selection === c.plant.id}
|
||||
onChange={() => setSelection(c.plant.id)}
|
||||
/>
|
||||
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
|
||||
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
|
||||
<span className="text-xs text-muted">{c.reason}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
|
||||
<input
|
||||
type="radio"
|
||||
name="packet-selection"
|
||||
checked={selection === 'new'}
|
||||
onChange={() => setSelection('new')}
|
||||
/>
|
||||
<span className="flex-1 font-medium text-fg">
|
||||
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{selection === 'new' && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
hint="You can set an icon and color later from the plant card."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
label="Category"
|
||||
name="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
||||
options={categoryOptions}
|
||||
/>
|
||||
<TextField
|
||||
label={`Spacing (${unitLabel})`}
|
||||
name="spacing"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="1"
|
||||
required
|
||||
value={spacing}
|
||||
onChange={(e) => setSpacing(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Days to maturity (optional)"
|
||||
name="days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The seed lot — what you bought — recorded against whichever plant. */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-fg">Seed lot</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Unit"
|
||||
name="unit"
|
||||
value={lotUnit}
|
||||
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
|
||||
options={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField
|
||||
label="Packed for"
|
||||
name="packedForYear"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="2026"
|
||||
value={packedForYear}
|
||||
onChange={(e) => setPackedForYear(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||
<TextField
|
||||
label="Cost"
|
||||
name="cost"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="4.99"
|
||||
value={cost}
|
||||
onChange={(e) => setCost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
// Clear the review-phase error too, or it would show on the
|
||||
// capture screen we're returning to.
|
||||
setError(null)
|
||||
setProposal(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact read-only summary of what the model pulled off the packet, so the
|
||||
* user can see the extraction at a glance while they confirm. Only fields that
|
||||
* came back are shown. */
|
||||
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
|
||||
const p = proposal.packet
|
||||
const rows: [string, string][] = []
|
||||
if (p.species) rows.push(['Species', p.species])
|
||||
if (p.variety) rows.push(['Variety', p.variety])
|
||||
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
|
||||
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
|
||||
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-muted">{k}</dt>
|
||||
<dd className="text-fg">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
|
||||
/**
|
||||
* A confirm-and-act dialog: a message, then Cancel / Confirm. It owns the shared
|
||||
* shape every confirmation repeated by hand — the busy lock, the inline error on
|
||||
* failure (so a 409 like PLANT_IN_USE is shown, not swallowed), and the footer —
|
||||
* so each caller supplies only its message, its action, and its labels.
|
||||
*
|
||||
* onConfirm runs the action; it resolving closes the dialog, it throwing keeps
|
||||
* the dialog open with the error and re-enables the button for a retry. This is
|
||||
* confirmations only — dialogs with their own inputs (a rename, a form) keep
|
||||
* using Modal directly.
|
||||
*/
|
||||
export function ConfirmModal({
|
||||
title,
|
||||
children,
|
||||
confirmLabel,
|
||||
busyLabel,
|
||||
confirmVariant = 'danger',
|
||||
confirmDisabled = false,
|
||||
errorFallback,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
/** The body — what's being confirmed. */
|
||||
children: ReactNode
|
||||
confirmLabel: string
|
||||
/** Label while the action is in flight (e.g. "Deleting…"). */
|
||||
busyLabel: string
|
||||
confirmVariant?: 'danger' | 'primary'
|
||||
/** Extra guard beyond busy (e.g. nothing to clear, no current user). */
|
||||
confirmDisabled?: boolean
|
||||
/** Message if the action throws something without its own user-facing text. */
|
||||
errorFallback: string
|
||||
onConfirm: () => Promise<unknown>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, errorFallback))
|
||||
setBusy(false) // keep the dialog open so the message shows and retry works
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={title} onClose={onClose} busy={busy}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{children}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmVariant}
|
||||
onClick={handleConfirm}
|
||||
disabled={busy || confirmDisabled}
|
||||
>
|
||||
{busy ? busyLabel : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
// Shared footer-action button styling for list cards (garden/plant), so the
|
||||
// verbatim class strings don't drift between them.
|
||||
//
|
||||
// Sized for touch: a ~40px-tall tap target (min-h + py-2) rather than the old
|
||||
// ~28px text-link row, which was easy to mis-tap on a phone (#105). The min-h is
|
||||
// what guarantees the target even when the label is short.
|
||||
|
||||
const cardActionBase =
|
||||
'inline-flex min-h-[2.5rem] items-center rounded-md px-3 py-2 text-sm font-medium ' +
|
||||
'text-muted outline-none transition-colors focus-visible:ring-2 '
|
||||
|
||||
export const cardActionClass =
|
||||
'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' +
|
||||
'hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||
cardActionBase + 'hover:bg-border/50 hover:text-fg focus-visible:ring-accent/40'
|
||||
|
||||
export const cardDangerClass =
|
||||
'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' +
|
||||
'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500/40 dark:hover:text-red-400'
|
||||
cardActionBase +
|
||||
'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-red-500/40 dark:hover:text-red-400'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@@ -14,8 +14,31 @@ import {
|
||||
type AgentTurn,
|
||||
} from '@/lib/agent'
|
||||
import { useUndo } from '@/lib/history'
|
||||
import { lazyPage } from '@/lib/lazyPage'
|
||||
import { UndoButton } from './UndoButton'
|
||||
|
||||
// Lazy so the markdown renderer + its ecosystem (~150 KB) loads only when an
|
||||
// assistant message actually renders, not for everyone who opens the editor.
|
||||
// lazyPage adds the stale-chunk recovery a plain lazy() lacks — a post-deploy
|
||||
// chunk 404 would otherwise permanently break the assistant.
|
||||
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
|
||||
|
||||
/**
|
||||
* Falls back to the raw message text if the markdown chunk can't load (a
|
||||
* non-recoverable 404) or the renderer throws — a garbled reply should degrade to
|
||||
* readable text, never take the whole editor down. Suspense handles the loading
|
||||
* phase; this handles the failure one.
|
||||
*/
|
||||
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true }
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Talk to the garden assistant, in the editor beside the canvas.
|
||||
*
|
||||
@@ -106,8 +129,8 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
||||
})
|
||||
}
|
||||
disabled={clear.isPending}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
disabled={clear.isPending || !!pending}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
@@ -120,7 +143,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
|
||||
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
|
||||
{/* A failed load rendering as an empty thread would look like the
|
||||
conversation had been lost, which is a much worse thing to believe. */}
|
||||
@@ -227,11 +250,27 @@ function Bubble({
|
||||
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
|
||||
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
|
||||
'rounded-lg px-2.5 py-2 text-sm',
|
||||
// The user's own text is literal (their `*` shouldn't become a bullet)
|
||||
// and hugs the right; the assistant's Markdown is rendered and gets the
|
||||
// full width so a table has room.
|
||||
mine
|
||||
? 'max-w-[90%] whitespace-pre-wrap bg-accent/15 text-fg'
|
||||
: 'w-full border border-border text-fg',
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
{mine ? (
|
||||
body
|
||||
) : (
|
||||
// Show the raw text until the renderer chunk arrives (Suspense), and fall
|
||||
// back to it if the chunk can't load or the renderer throws (boundary) —
|
||||
// either way the message is readable, never blank and never a crash.
|
||||
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||
<MarkdownMessage>{body}</MarkdownMessage>
|
||||
</Suspense>
|
||||
</MarkdownBoundary>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useClearObject } from '@/lib/objects'
|
||||
|
||||
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
||||
@@ -19,27 +18,20 @@ export function ClearBedModal({
|
||||
}) {
|
||||
const clear = useClearObject(gardenId)
|
||||
return (
|
||||
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={clear.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={clear.isPending || plopCount === 0}
|
||||
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
|
||||
>
|
||||
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
title="Clear bed"
|
||||
confirmLabel="Clear bed"
|
||||
busyLabel="Clearing…"
|
||||
confirmDisabled={plopCount === 0}
|
||||
errorFallback="Could not clear the bed."
|
||||
onConfirm={() => clear.mutateAsync(objectId)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ import { cn } from '@/lib/cn'
|
||||
* - Selecting an object must land you in the inspector with no extra click.
|
||||
* The editor watches the selection and switches to that tab itself, so the
|
||||
* rail never becomes a thing you have to operate before you can edit.
|
||||
* - The canvas has to stay worth watching while the agent edits it, so the
|
||||
* rail is a fixed 20rem column and closes completely when nothing needs it.
|
||||
* - The canvas has to stay worth watching while the agent edits it, so the rail
|
||||
* closes completely when nothing needs it.
|
||||
*
|
||||
* On a phone the same tabs render in a bottom sheet, which is where the
|
||||
* inspector already lived.
|
||||
* Layout differs by breakpoint. Desktop: a fixed 20rem column beside the canvas.
|
||||
* Phone: an in-flow PEEK (#101) — a capped-height panel the editor's flex column
|
||||
* places BETWEEN the canvas and the always-visible mode bar, so the canvas
|
||||
* shrinks to keep the garden visible above it and the mode bar reachable below,
|
||||
* rather than a bottom sheet that covered the whole garden. `tall` raises that
|
||||
* cap for panel modes (journal/history/assistant), where reading and typing are
|
||||
* the task and a half-height peek felt cramped; the inspector keeps the shorter
|
||||
* peek so the canvas it describes stays in view.
|
||||
*/
|
||||
|
||||
export interface RailTab {
|
||||
@@ -35,11 +41,14 @@ export function EditorRail({
|
||||
activeId,
|
||||
onActivate,
|
||||
onClose,
|
||||
tall = false,
|
||||
}: {
|
||||
tabs: RailTab[]
|
||||
activeId: string
|
||||
onActivate: (id: string) => void
|
||||
onClose: () => void
|
||||
/** Raise the mobile peek's height cap (panel modes want the room). */
|
||||
tall?: boolean
|
||||
}) {
|
||||
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
|
||||
if (!active) return null
|
||||
@@ -47,9 +56,17 @@ export function EditorRail({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Phone: a bottom sheet over the canvas. Desktop: a column beside it.
|
||||
'fixed inset-x-0 bottom-0 z-30 flex max-h-[70vh] flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
||||
'md:static md:max-h-none md:w-80 md:shrink-0 md:rounded-xl md:border md:shadow-sm',
|
||||
// Phone: an in-flow PEEK — a capped-height panel that sits between the
|
||||
// canvas and the always-visible mode bar (the editor's flex column places
|
||||
// it there), so the garden stays visible above it and the mode bar stays
|
||||
// reachable below. The canvas flexes to fill whatever's left. Desktop: a
|
||||
// fixed-width column beside the canvas (the cap doesn't apply there).
|
||||
'flex min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
||||
// dvh, not vh: the enclosing editor column is dvh-bounded, and on mobile
|
||||
// Safari/Chrome vh is the *largest* viewport, so a vh cap could overrun the
|
||||
// visible area and shove the mode bar off-screen (same #85 reasoning).
|
||||
tall ? 'max-h-[78dvh]' : 'max-h-[50dvh]',
|
||||
'md:static md:max-h-none md:w-80 md:rounded-xl md:border md:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
|
||||
@@ -37,6 +37,8 @@ export function JournalPanel({
|
||||
objects,
|
||||
scopeObjectId,
|
||||
onScopeChange,
|
||||
scopePlantingId,
|
||||
onScopePlantingChange,
|
||||
}: {
|
||||
gardenId: number
|
||||
canEdit: boolean
|
||||
@@ -46,19 +48,30 @@ export function JournalPanel({
|
||||
/** Which bed the panel is filtered to, if any. */
|
||||
scopeObjectId: number | null
|
||||
onScopeChange: (id: number | null) => void
|
||||
/** Which single plop the panel is filtered to, if any (#85). The store keeps
|
||||
* this mutually exclusive with scopeObjectId. Required like its bed twin. */
|
||||
scopePlantingId: number | null
|
||||
onScopePlantingChange: (id: number | null) => void
|
||||
}) {
|
||||
// Date-range narrowing (#85): the backend and JournalFilter already supported
|
||||
// from/to; they just had no UI. Empty inputs don't filter.
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||
// One source of scope priority — plop over bed — for both the filter and the
|
||||
// composer's label, so they can't drift.
|
||||
const scopeLabel = scopePlantingId != null ? 'this planting' : scopedObject ? objectDisplayName(scopedObject) : null
|
||||
const filter = {
|
||||
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
|
||||
...(scopePlantingId != null
|
||||
? { plantingId: scopePlantingId }
|
||||
: scopeObjectId != null
|
||||
? { objectId: scopeObjectId }
|
||||
: {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
}
|
||||
const journal = useJournal(gardenId, filter)
|
||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -83,6 +96,19 @@ export function JournalPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scopePlantingId != null && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md bg-accent/10 px-2 py-1 text-xs">
|
||||
<span className="text-accent-strong">Notes about one planting</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onScopePlantingChange(null)}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<label className="flex items-center gap-1">
|
||||
<span>From</span>
|
||||
@@ -121,8 +147,9 @@ export function JournalPanel({
|
||||
{canEdit && (
|
||||
<Composer
|
||||
gardenId={gardenId}
|
||||
objectId={scopeObjectId}
|
||||
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null}
|
||||
objectId={scopePlantingId != null ? null : scopeObjectId}
|
||||
plantingId={scopePlantingId}
|
||||
scopeLabel={scopeLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -133,9 +160,11 @@ export function JournalPanel({
|
||||
|
||||
{journal.isSuccess && entries.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
{scopedObject
|
||||
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
|
||||
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
|
||||
{scopePlantingId != null
|
||||
? 'Nothing written about this planting yet.'
|
||||
: scopedObject
|
||||
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
|
||||
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -174,10 +203,13 @@ export function JournalPanel({
|
||||
function Composer({
|
||||
gardenId,
|
||||
objectId,
|
||||
plantingId = null,
|
||||
scopeLabel,
|
||||
}: {
|
||||
gardenId: number
|
||||
objectId: number | null
|
||||
/** When set, the note attaches to this plop rather than a bed (#85). */
|
||||
plantingId?: number | null
|
||||
scopeLabel: string | null
|
||||
}) {
|
||||
const create = useCreateJournalEntry(gardenId)
|
||||
@@ -191,7 +223,7 @@ function Composer({
|
||||
if (!text) return
|
||||
setError(null)
|
||||
create.mutate(
|
||||
{ body: text, observedAt, objectId: objectId ?? undefined },
|
||||
{ body: text, observedAt, objectId: objectId ?? undefined, plantingId: plantingId ?? undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setBody('')
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MarkdownMessage } from './MarkdownMessage'
|
||||
|
||||
// renderToStaticMarkup needs no DOM, so this runs in the default (node) env and
|
||||
// proves the assistant's Markdown — GFM tables in particular — actually renders.
|
||||
function render(md: string): string {
|
||||
return renderToStaticMarkup(<MarkdownMessage>{md}</MarkdownMessage>)
|
||||
}
|
||||
|
||||
describe('MarkdownMessage', () => {
|
||||
it('renders a GFM table with styled cells', () => {
|
||||
const html = render('| Bed | Plant |\n| --- | --- |\n| North | Garlic |')
|
||||
expect(html).toContain('<table')
|
||||
expect(html).toContain('border-collapse')
|
||||
expect(html).toContain('<th')
|
||||
expect(html).toContain('<td')
|
||||
expect(html).toContain('Garlic')
|
||||
// Wide tables scroll inside their own box rather than blowing out the bubble.
|
||||
expect(html).toContain('overflow-x-auto')
|
||||
})
|
||||
|
||||
it('renders inline formatting and lists', () => {
|
||||
const html = render('**bold** and *italic*\n\n- one\n- two')
|
||||
expect(html).toContain('<strong')
|
||||
expect(html).toContain('<em')
|
||||
expect(html).toContain('<ul')
|
||||
expect(html).toContain('<li')
|
||||
})
|
||||
|
||||
it('does not emit raw HTML from the model (no rehype-raw)', () => {
|
||||
const html = render('Hi <script>alert(1)</script> <b>x</b>')
|
||||
expect(html).not.toContain('<script>')
|
||||
expect(html).not.toContain('<b>x</b>') // the literal tag is escaped, not rendered
|
||||
})
|
||||
|
||||
it('opens links safely in a new tab', () => {
|
||||
const html = render('[seeds](https://example.com)')
|
||||
expect(html).toContain('href="https://example.com"')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
})
|
||||
|
||||
it('does not render images (no auto-loading exfiltration beacon)', () => {
|
||||
// A prompt-injected reply could embed ``; the browser
|
||||
// would auto-fetch it, leaking that the message was viewed (and anything smuggled
|
||||
// into the URL). We forbid <img> entirely, so the beacon never fires.
|
||||
const html = render('before  after')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('evil.example')
|
||||
// Surrounding prose still renders.
|
||||
expect(html).toContain('before')
|
||||
expect(html).toContain('after')
|
||||
})
|
||||
|
||||
it('honours GFM column alignment', () => {
|
||||
const html = render('| L | C | R |\n| :-- | :--: | --: |\n| a | b | c |')
|
||||
expect(html).toContain('text-align:center')
|
||||
expect(html).toContain('text-align:right')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
// Hoisted so they're not re-created every render (which would defeat both React's
|
||||
// and ReactMarkdown's memoization).
|
||||
const remarkPlugins = [remarkGfm]
|
||||
const CODE_BLOCK = /language-/
|
||||
|
||||
// The assistant's replies are never trusted markup: their content can be steered
|
||||
// by anything the agent read (a shared garden's notes, a seed vendor page). So we
|
||||
// render Markdown but NOT raw HTML (no rehype-raw), and — belt to that — forbid
|
||||
// <img>, whose auto-loading `src` is a prompt-injection exfiltration beacon
|
||||
// (``); the assistant has no reason to emit images.
|
||||
const disallowedElements = ['img']
|
||||
|
||||
// Tailwind's reset strips default list/table styling, so every element the
|
||||
// assistant actually uses is restyled here, scaled for a chat bubble. Wide
|
||||
// content (tables, code) scrolls in its own box so the bubble never blows out.
|
||||
const bigHeading = ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className="mb-1 mt-2 text-sm font-semibold first:mt-0">{children}</h4>
|
||||
)
|
||||
const smallHeading = ({ children }: { children?: ReactNode }) => (
|
||||
<h5 className="mb-1 mt-1.5 text-xs font-semibold uppercase tracking-wide text-muted first:mt-0">
|
||||
{children}
|
||||
</h5>
|
||||
)
|
||||
|
||||
const components: Components = {
|
||||
p: ({ children }) => <p className="my-1.5 first:mt-0 last:mb-0">{children}</p>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-strong underline underline-offset-2"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||
ol: ({ children, start }) => (
|
||||
<ol start={start} className="my-1.5 list-decimal pl-5">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
h1: bigHeading,
|
||||
h2: bigHeading,
|
||||
h3: bigHeading,
|
||||
h4: smallHeading,
|
||||
h5: smallHeading,
|
||||
h6: smallHeading,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-1.5 border-l-2 border-border pl-2 text-muted">{children}</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-2 border-border" />,
|
||||
code: ({ className, children }) => {
|
||||
// A fenced block is wrapped by <pre> (styled below) and carries either a
|
||||
// language- class or a trailing newline; inline code is a single-line bare
|
||||
// <code> and gets the pill treatment. (The newline check catches fences with
|
||||
// no info-string, which have no language- class.)
|
||||
const isBlock = CODE_BLOCK.test(className ?? '') || String(children).includes('\n')
|
||||
if (isBlock) return <code className={className}>{children}</code>
|
||||
return (
|
||||
<code className="rounded bg-border/60 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-1.5 overflow-x-auto rounded-md bg-border/40 p-2 font-mono text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-1.5 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
// Pass `style` through: GFM column alignment (`:---:` / `---:`) arrives as
|
||||
// style.textAlign, and dropping it would silently discard it.
|
||||
th: ({ children, style }) => (
|
||||
<th style={style} className="border border-border px-2 py-1 font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, style }) => (
|
||||
<td style={style} className="border border-border px-2 py-1 align-top">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}
|
||||
|
||||
/** Render one assistant message body as Markdown (GFM). Memoized so typing in the
|
||||
* composer doesn't re-parse every message in the thread. */
|
||||
export const MarkdownMessage = memo(function MarkdownMessage({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
disallowedElements={disallowedElements}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -25,6 +25,7 @@ export function PlopInspector({
|
||||
unit,
|
||||
onChangePlant,
|
||||
onClose,
|
||||
onAddNote,
|
||||
readOnly = false,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
@@ -33,6 +34,11 @@ export function PlopInspector({
|
||||
unit: UnitPref
|
||||
onChangePlant: () => void
|
||||
onClose: () => void
|
||||
/** Scope the journal to this plop and open it — the plop parallel of the bed
|
||||
* inspector's "add note" (#85). Offered to viewers too (to READ the plop's
|
||||
* notes, like the bed inspector does); the journal's composer is separately
|
||||
* gated on edit rights, so a viewer just sees the entries. */
|
||||
onAddNote?: () => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
@@ -172,6 +178,12 @@ export function PlopInspector({
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{onAddNote && (
|
||||
<Button variant="ghost" className="justify-start px-2 py-1 text-sm" onClick={onAddNote}>
|
||||
📓 {readOnly ? 'Notes about this plant' : 'Add a note about this plant'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PlantChip } from '@/components/plants/PlantChip'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
|
||||
* so re-placing "more of the same" is one tap instead of a trip through the
|
||||
* catalog or the manual tray. Derived from actual plantings (see
|
||||
* recentlyPlantedIds), newest first; renders nothing until something's planted.
|
||||
* Tap a chip to arm it for placement (the armed one is highlighted).
|
||||
*/
|
||||
export function RecentPlants({
|
||||
plants,
|
||||
armedPlantId,
|
||||
onArm,
|
||||
}: {
|
||||
plants: Plant[]
|
||||
armedPlantId: number | null
|
||||
onArm: (plant: Plant) => void
|
||||
}) {
|
||||
if (plants.length === 0) return null
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||
<span className="shrink-0 text-[0.7rem] font-medium uppercase tracking-wide text-muted">Recent</span>
|
||||
{plants.map((p) => (
|
||||
<PlantChip key={p.id} plant={p} active={p.id === armedPlantId} onArm={onArm} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
-19
@@ -1,5 +1,5 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { PlantChip } from '@/components/plants/PlantChip'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
@@ -26,28 +26,19 @@ export function SeedTray({
|
||||
{trayPlants.map((p) => {
|
||||
const active = p.id === armedPlantId
|
||||
return (
|
||||
<span
|
||||
key={p.id}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border text-xs transition-colors',
|
||||
active ? 'border-accent bg-accent/10 text-accent-strong' : 'border-border bg-surface text-fg',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArm(p)}
|
||||
aria-pressed={active}
|
||||
title={active ? `Placing ${p.name}` : `Place ${p.name}`}
|
||||
className="flex items-center gap-1.5 rounded-full py-1 pl-1.5 pr-1 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<PlantIcon color={p.color} icon={p.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
|
||||
<span className="max-w-[6rem] truncate font-medium">{p.name}</span>
|
||||
</button>
|
||||
<span key={p.id} className="inline-flex items-center">
|
||||
{/* Flat right edge so the remove button below seams into one pill. */}
|
||||
<PlantChip plant={p} active={active} onArm={onArm} rounded={false} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(p.id)}
|
||||
aria-label={`Remove ${p.name} from tray`}
|
||||
className="rounded-full px-1.5 py-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className={cn(
|
||||
'rounded-r-full border border-l-0 px-1.5 py-1 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent-strong hover:text-fg'
|
||||
: 'border-border bg-surface text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
// one place instead of drifting between files.
|
||||
|
||||
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
|
||||
export const HANDLE_PX = 12 // on-screen size of a drag/resize handle
|
||||
// Whether the primary pointer is a fingertip rather than a mouse — the one signal
|
||||
// the touch affordances key off (bigger handles here, the on-screen nudge pad in
|
||||
// the editor), so they can't disagree about what "touch" means. Read once at
|
||||
// load; a device doesn't switch its primary pointer mid-session, and the optional
|
||||
// chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent.
|
||||
export const isCoarsePointer =
|
||||
typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches
|
||||
// On-screen size of a drag/resize handle. Bigger on touch so a fingertip can
|
||||
// actually grab a resize corner or the rotate knob — 12px is fine for a mouse but
|
||||
// frustrating for a thumb (#104).
|
||||
export const HANDLE_PX = isCoarsePointer ? 22 : 12
|
||||
export const MIN_RADIUS_CM = 1 // smallest plop radius
|
||||
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
|
||||
|
||||
|
||||
+31
-1
@@ -11,10 +11,25 @@ import type { EditorObject } from './types'
|
||||
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
||||
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
||||
|
||||
// The editor's one primary activity (#99). On mobile this is the segmented
|
||||
// control at the bottom of the screen; it decides which tools dock there —
|
||||
// placing fixtures, placing plants, the journal, or the assistant — so the four
|
||||
// activities stop competing for the same cramped strip. Desktop keeps its
|
||||
// side-column layout and treats this as a lighter hint.
|
||||
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
|
||||
|
||||
// Where the editor starts, and where it returns on a garden switch.
|
||||
export const DEFAULT_MODE: EditorMode = 'fixtures'
|
||||
|
||||
interface EditorState {
|
||||
viewport: Viewport
|
||||
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
||||
|
||||
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
|
||||
// last using is not a property of the garden.
|
||||
mode: EditorMode
|
||||
setMode: (mode: EditorMode) => void
|
||||
|
||||
// The selected object OR plop (mutually exclusive; selecting one clears the
|
||||
// other). selectedId is a garden object; selectedPlantingId is a plop.
|
||||
selectedId: number | null
|
||||
@@ -44,6 +59,12 @@ interface EditorState {
|
||||
journalObjectId: number | null
|
||||
setJournalObjectId: (id: number | null) => void
|
||||
|
||||
// Which single plop the journal is filtered to, if any — the parallel of
|
||||
// journalObjectId for a planting (#85). The two scopes are mutually exclusive
|
||||
// (the setters clear each other), so the journal filter is never ambiguous.
|
||||
journalPlantingId: number | null
|
||||
setJournalPlantingId: (id: number | null) => void
|
||||
|
||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
@@ -82,6 +103,9 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
viewport: { tx: 0, ty: 0, scale: 1 },
|
||||
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
||||
|
||||
mode: DEFAULT_MODE,
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
selectedId: null,
|
||||
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
|
||||
|
||||
@@ -98,7 +122,11 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
setSeasonYear: (year) => set({ seasonYear: year }),
|
||||
|
||||
journalObjectId: null,
|
||||
setJournalObjectId: (id) => set({ journalObjectId: id }),
|
||||
// Scoping to a bed clears any plop scope, so only one is ever active.
|
||||
setJournalObjectId: (id) => set({ journalObjectId: id, journalPlantingId: null }),
|
||||
|
||||
journalPlantingId: null,
|
||||
setJournalPlantingId: (id) => set({ journalPlantingId: id, journalObjectId: null }),
|
||||
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
@@ -129,5 +157,7 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
railTab: null,
|
||||
seasonYear: null,
|
||||
journalObjectId: null,
|
||||
journalPlantingId: null,
|
||||
mode: DEFAULT_MODE,
|
||||
}),
|
||||
}))
|
||||
|
||||
+17
-8
@@ -11,18 +11,27 @@ import { API_BASE, api } from './api'
|
||||
import { gardenFullKey } from './objects'
|
||||
import { historyKey } from './history'
|
||||
|
||||
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
||||
// What this instance can actually do, so the UI offers only what works: `agent`
|
||||
// (the assistant is live now) and `vision` (a seed-packet scan will work). Both
|
||||
// default to false so a partial/older response just hides the feature rather
|
||||
// than failing the whole parse.
|
||||
const capabilitiesSchema = z.object({
|
||||
agent: z.boolean().default(false),
|
||||
vision: z.boolean().default(false),
|
||||
})
|
||||
export type Capabilities = z.infer<typeof capabilitiesSchema>
|
||||
|
||||
export const capabilitiesKey = ['capabilities'] as const
|
||||
|
||||
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
|
||||
* at all — a dead button is worse than no button.
|
||||
/** What this instance can do right now: whether the assistant is live and whether
|
||||
* seed-packet scanning will work. Without a capability the matching feature isn't
|
||||
* offered at all — a dead button is worse than no button.
|
||||
*
|
||||
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
|
||||
* in Settings (#79), so this must be able to change under a running page. The
|
||||
* settings save invalidates this key directly; the finite staleTime just means
|
||||
* another admin's change is picked up on the next focus/remount rather than
|
||||
* never. */
|
||||
* Not `staleTime: Infinity` any more: an admin can turn the assistant or a vision
|
||||
* model on or off in Settings (#79), so this must be able to change under a
|
||||
* running page. The settings save invalidates this key directly; the finite
|
||||
* staleTime just means another admin's change is picked up on the next
|
||||
* focus/remount rather than never. */
|
||||
export function useCapabilities() {
|
||||
return useQuery({
|
||||
queryKey: capabilitiesKey,
|
||||
|
||||
+18
-6
@@ -30,6 +30,11 @@ export class ApiError extends Error {
|
||||
get isUnauthorized(): boolean {
|
||||
return this.status === 401
|
||||
}
|
||||
|
||||
/** Resource is gone or masked (pansy returns 404 for no-access, not 403). */
|
||||
get isNotFound(): boolean {
|
||||
return this.status === 404
|
||||
}
|
||||
}
|
||||
|
||||
type ParamValue = string | number | boolean | undefined | null
|
||||
@@ -37,7 +42,8 @@ export type Params = Record<string, ParamValue>
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
/** JSON request body; serialized and sent with a JSON content-type. */
|
||||
/** Request body. A `FormData` goes out as multipart (a file upload — the
|
||||
* seed-packet scan); anything else is serialized as JSON. */
|
||||
body?: unknown
|
||||
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
||||
params?: Params
|
||||
@@ -85,9 +91,13 @@ function messageFrom(body: unknown, status: number): string {
|
||||
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, params, signal } = opts
|
||||
|
||||
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
|
||||
// surfaces as itself, not as a misleading "cannot reach the server" error.
|
||||
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
|
||||
// FormData must go out as multipart with a browser-generated boundary, so it's
|
||||
// sent as-is with NO content-type header (the browser sets it, boundary
|
||||
// included). Everything else is JSON — serialized before the try so a
|
||||
// JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a
|
||||
// misleading "cannot reach the server" error.
|
||||
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
||||
const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
@@ -97,9 +107,9 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
|
||||
credentials: 'same-origin', // send the HttpOnly session cookie
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
body: requestBody,
|
||||
body: requestBody as BodyInit | undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === 'AbortError') throw err
|
||||
@@ -138,6 +148,8 @@ export const api = {
|
||||
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
||||
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
||||
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'POST', body: form }),
|
||||
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
||||
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getLastGardenId, rememberLastGarden, forgetLastGarden } from './lastGarden'
|
||||
|
||||
// The tests run in the node environment (no DOM), so stand up a minimal
|
||||
// in-memory localStorage rather than pull in jsdom for one thin module.
|
||||
function installStorage(impl?: Partial<Storage>) {
|
||||
const store = new Map<string, string>()
|
||||
const base: Storage = {
|
||||
getItem: (k) => store.get(k) ?? null,
|
||||
setItem: (k, v) => void store.set(k, String(v)),
|
||||
removeItem: (k) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
key: (i) => [...store.keys()][i] ?? null,
|
||||
get length() {
|
||||
return store.size
|
||||
},
|
||||
}
|
||||
;(globalThis as { localStorage?: Storage }).localStorage = { ...base, ...impl }
|
||||
}
|
||||
|
||||
beforeEach(() => installStorage())
|
||||
afterEach(() => {
|
||||
delete (globalThis as { localStorage?: Storage }).localStorage
|
||||
})
|
||||
|
||||
describe('lastGarden', () => {
|
||||
it('round-trips a remembered garden id', () => {
|
||||
expect(getLastGardenId()).toBeNull()
|
||||
rememberLastGarden(42)
|
||||
expect(getLastGardenId()).toBe(42)
|
||||
})
|
||||
|
||||
it('rejects a non-positive or unparseable stored value', () => {
|
||||
localStorage.setItem('pansy:last-garden', 'not-a-number')
|
||||
expect(getLastGardenId()).toBeNull()
|
||||
localStorage.setItem('pansy:last-garden', '0')
|
||||
expect(getLastGardenId()).toBeNull()
|
||||
localStorage.setItem('pansy:last-garden', '-3')
|
||||
expect(getLastGardenId()).toBeNull()
|
||||
})
|
||||
|
||||
it('forgets unconditionally with no argument', () => {
|
||||
rememberLastGarden(7)
|
||||
forgetLastGarden()
|
||||
expect(getLastGardenId()).toBeNull()
|
||||
})
|
||||
|
||||
it('forgets only when the stored id matches onlyIfEquals', () => {
|
||||
rememberLastGarden(5)
|
||||
// A 404 on a different (directly-linked) garden must not wipe the good resume.
|
||||
forgetLastGarden(9)
|
||||
expect(getLastGardenId()).toBe(5)
|
||||
// A 404 on the stored garden itself does clear it.
|
||||
forgetLastGarden(5)
|
||||
expect(getLastGardenId()).toBeNull()
|
||||
})
|
||||
|
||||
it('swallows storage failures instead of throwing', () => {
|
||||
installStorage({
|
||||
setItem: () => {
|
||||
throw new Error('quota')
|
||||
},
|
||||
getItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
})
|
||||
expect(() => rememberLastGarden(1)).not.toThrow()
|
||||
expect(getLastGardenId()).toBeNull() // getItem throwing → null, not a crash
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// The last garden opened on THIS device, so a returning user resumes where they
|
||||
// were instead of always landing on the gardens list. Per-device, in
|
||||
// localStorage (same rationale as the seed tray and PlantPicker recents): a
|
||||
// convenience, not authoritative state — a quota/availability failure is
|
||||
// swallowed, and a stored id that no longer loads clears itself (see the editor).
|
||||
//
|
||||
// Only the id is stored. The garden is resolved by the editor on load; if it's
|
||||
// gone (deleted, or access revoked), forgetLastGarden() drops it so `/` stops
|
||||
// resuming a garden that can't open.
|
||||
|
||||
const KEY = 'pansy:last-garden'
|
||||
|
||||
/** The last-opened garden id on this device, or null if none/unparseable. */
|
||||
export function getLastGardenId(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (raw == null) return null
|
||||
const n = Number(raw)
|
||||
return Number.isInteger(n) && n > 0 ? n : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the garden the device is now in, so `/` resumes here next time. */
|
||||
export function rememberLastGarden(id: number): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(id))
|
||||
} catch {
|
||||
// Resume is a convenience; ignore quota/availability failures.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the stored last garden. With `onlyIfEquals`, clears only when the stored
|
||||
* id matches — so a 404 on a directly-linked garden can't wipe a different, still
|
||||
* good resume target the user had.
|
||||
*/
|
||||
export function forgetLastGarden(onlyIfEquals?: number): void {
|
||||
try {
|
||||
if (onlyIfEquals != null && getLastGardenId() !== onlyIfEquals) return
|
||||
localStorage.removeItem(KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { lazy, type ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* Lazily load a component by its named export, with recovery for the stale-chunk
|
||||
* problem. A push to main redeploys, so a still-open app references chunk hashes
|
||||
* the server has just replaced; that import 404s and React.lazy MEMOIZES the
|
||||
* rejection, so a "Try again" can never recover — the user is stuck until a manual
|
||||
* hard reload. On the first such failure we reload once (fetching the fresh index
|
||||
* + hashes); a session flag stops a reload loop, and a success clears it so a
|
||||
* later genuine failure can reload again.
|
||||
*
|
||||
* Shared by the route splits (router.tsx) and any feature-level lazy load (the
|
||||
* assistant's Markdown renderer), so they all get the same recovery.
|
||||
*/
|
||||
export function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||
// Preserve the component's own prop type so callers keep type-checked props
|
||||
// (e.g. MarkdownMessage's `children: string`), rather than erasing to `{}`.
|
||||
type C = M[K] extends ComponentType<infer P> ? ComponentType<P> : never
|
||||
return lazy<C>(async () => {
|
||||
try {
|
||||
const mod = await load()
|
||||
try {
|
||||
sessionStorage.removeItem('pansy:chunk-reload')
|
||||
} catch {
|
||||
/* storage unavailable — fine */
|
||||
}
|
||||
return { default: mod[name] as C }
|
||||
} catch (err) {
|
||||
try {
|
||||
if (!sessionStorage.getItem('pansy:chunk-reload')) {
|
||||
sessionStorage.setItem('pansy:chunk-reload', '1')
|
||||
window.location.reload()
|
||||
return await new Promise<{ default: C }>(() => {}) // hold for the reload
|
||||
}
|
||||
} catch {
|
||||
/* storage unavailable — fall through to surface the error */
|
||||
}
|
||||
throw err // already reloaded once (or can't); let the error boundary show it
|
||||
}
|
||||
})
|
||||
}
|
||||
+35
-1
@@ -329,6 +329,38 @@ export function useUpdatePlanting(gardenId: number) {
|
||||
}
|
||||
|
||||
const clearResultSchema = z.object({ cleared: z.number() })
|
||||
const fillResultSchema = z.object({ created: z.number() })
|
||||
|
||||
/** Fill mode (#77/#100): the layout a region fill packs — fat clumps for quick
|
||||
* coverage, or a grid of individual plants at true spacing you could plant from.
|
||||
* Passed straight through to the server's `layout` field. */
|
||||
export type FillLayout = 'clump' | 'grid'
|
||||
|
||||
/** Fill a whole plantable object with one plant at the chosen layout, via the
|
||||
* same `POST /objects/:id/fill` the agent uses (region "all"). The response is
|
||||
* just a count; invalidate rather than optimistically splice a hex lattice we'd
|
||||
* have to recompute client-side. Returns how many plops it created. */
|
||||
export function useFillObject(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
objectId,
|
||||
plantId,
|
||||
layout,
|
||||
}: {
|
||||
objectId: number
|
||||
plantId: number
|
||||
layout: FillLayout
|
||||
}): Promise<number> => {
|
||||
const res = fillResultSchema.parse(
|
||||
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }),
|
||||
)
|
||||
return res.created
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
||||
onError: (err) => toast.error(objectErrorMessage(err, 'Could not fill the bed.')),
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear a bed: soft-remove every active plop in an object (#82).
|
||||
*
|
||||
@@ -349,7 +381,9 @@ export function useClearObject(gardenId: number) {
|
||||
return res.cleared
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
||||
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),
|
||||
// No toast: the only caller (ClearBedModal → ConfirmModal) shows the failure
|
||||
// inline in the dialog, which is more contextual than a detached toast — and
|
||||
// two of them for one failure is worse than one.
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeDerivedCount, effectiveCount } from './plantings'
|
||||
import { computeDerivedCount, effectiveCount, recentlyPlantedIds, type EditorPlanting } from './plantings'
|
||||
|
||||
function plop(over: Partial<EditorPlanting>): EditorPlanting {
|
||||
return {
|
||||
id: 0,
|
||||
objectId: 1,
|
||||
plantId: 1,
|
||||
xCm: 0,
|
||||
yCm: 0,
|
||||
radiusCm: 5,
|
||||
count: null,
|
||||
derivedCount: 1,
|
||||
label: null,
|
||||
plantedAt: null,
|
||||
version: 1,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('recentlyPlantedIds', () => {
|
||||
it('returns unique plant ids, newest planted first', () => {
|
||||
const got = recentlyPlantedIds([
|
||||
plop({ id: 1, plantId: 10, plantedAt: '2026-05-01' }),
|
||||
plop({ id: 2, plantId: 20, plantedAt: '2026-07-01' }),
|
||||
plop({ id: 3, plantId: 10, plantedAt: '2026-06-01' }), // dup plant, later
|
||||
])
|
||||
// 20 (Jul) before 10 (its most recent plop is Jun); 10 appears once.
|
||||
expect(got).toEqual([20, 10])
|
||||
})
|
||||
|
||||
it('breaks a same-day tie by newer plop id, and sorts undated last', () => {
|
||||
const got = recentlyPlantedIds([
|
||||
plop({ id: 5, plantId: 30, plantedAt: null }),
|
||||
plop({ id: 6, plantId: 40, plantedAt: '2026-07-01' }),
|
||||
plop({ id: 7, plantId: 50, plantedAt: '2026-07-01' }),
|
||||
])
|
||||
expect(got).toEqual([50, 40, 30]) // id 7 > 6 on the same day; undated 30 last
|
||||
})
|
||||
|
||||
it('is empty for no plantings', () => {
|
||||
expect(recentlyPlantedIds([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDerivedCount', () => {
|
||||
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
|
||||
|
||||
@@ -60,6 +60,27 @@ export function effectiveCount(p: { count: number | null; derivedCount: number }
|
||||
return p.count ?? p.derivedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant ids a garden has been planted with, most recent first and de-duplicated —
|
||||
* the "what have I actually been planting here" quick list (#100). Ordered by
|
||||
* plantedAt (a plop with no date sorts last), then by id so newer plops win a
|
||||
* same-day tie. The caller resolves the ids to plants against the catalog.
|
||||
*/
|
||||
export function recentlyPlantedIds(plantings: EditorPlanting[]): number[] {
|
||||
const sorted = [...plantings].sort(
|
||||
(a, b) => (b.plantedAt ?? '').localeCompare(a.plantedAt ?? '') || b.id - a.id,
|
||||
)
|
||||
const seen = new Set<number>()
|
||||
const ids: number[] = []
|
||||
for (const p of sorted) {
|
||||
if (!seen.has(p.plantId)) {
|
||||
seen.add(p.plantId)
|
||||
ids.push(p.plantId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Client-side mirror of the server's derived-count formula, for live display
|
||||
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
|
||||
* spacing²)), capped like the server. */
|
||||
|
||||
@@ -61,7 +61,7 @@ export function filterPlants(plants: Plant[], query: string, category: CategoryF
|
||||
)
|
||||
}
|
||||
|
||||
const plantsKey = ['plants'] as const
|
||||
export const plantsKey = ['plants'] as const
|
||||
|
||||
export const plantsQueryOptions = queryOptions({
|
||||
queryKey: plantsKey,
|
||||
|
||||
@@ -46,7 +46,7 @@ export const seedLotSchema = z.object({
|
||||
})
|
||||
export type SeedLot = z.infer<typeof seedLotSchema>
|
||||
|
||||
const seedLotsKey = ['seed-lots'] as const
|
||||
export const seedLotsKey = ['seed-lots'] as const
|
||||
|
||||
export function useSeedLots() {
|
||||
return useQuery({
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
lotDefaults,
|
||||
newPlantDefaults,
|
||||
packetProposalSchema,
|
||||
seedPacketSchema,
|
||||
PACKET_PLANT_COLOR,
|
||||
PACKET_PLANT_ICON,
|
||||
} from './seedPacket'
|
||||
|
||||
// A full proposal as the backend sends it, for the schema + prefill tests.
|
||||
const proposal = {
|
||||
packet: {
|
||||
species: 'garlic',
|
||||
variety: 'Music',
|
||||
category: 'vegetable',
|
||||
vendor: "Johnny's",
|
||||
sku: 'G-123',
|
||||
lotCode: 'L9',
|
||||
packedForYear: 2026,
|
||||
daysToMaturity: 90,
|
||||
spacingCm: 15,
|
||||
seedCount: 12,
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
plant: {
|
||||
id: 7,
|
||||
name: 'Music',
|
||||
category: 'vegetable',
|
||||
spacingCm: 15,
|
||||
color: '#fff',
|
||||
icon: '🧄',
|
||||
notes: '',
|
||||
version: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
reason: 'exact name',
|
||||
},
|
||||
],
|
||||
suggestedName: 'Music',
|
||||
suggestedCategory: 'vegetable',
|
||||
}
|
||||
|
||||
describe('seedPacketSchema', () => {
|
||||
it('fills defaults for a sparse packet (only what was printed)', () => {
|
||||
// A packet where the model only read a species — everything else absent.
|
||||
const p = seedPacketSchema.parse({ species: 'basil' })
|
||||
expect(p.species).toBe('basil')
|
||||
expect(p.variety).toBe('')
|
||||
expect(p.spacingCm).toBeNull()
|
||||
expect(p.seedCount).toBeNull()
|
||||
expect(p.packedForYear).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('packetProposalSchema', () => {
|
||||
it('parses a full proposal including candidates', () => {
|
||||
const p = packetProposalSchema.parse(proposal)
|
||||
expect(p.candidates).toHaveLength(1)
|
||||
expect(p.candidates[0].plant.id).toBe(7)
|
||||
expect(p.candidates[0].reason).toBe('exact name')
|
||||
expect(p.suggestedName).toBe('Music')
|
||||
})
|
||||
|
||||
it('defaults candidates to empty when absent', () => {
|
||||
const p = packetProposalSchema.parse({ packet: { species: 'kale' } })
|
||||
expect(p.candidates).toEqual([])
|
||||
expect(p.suggestedName).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('newPlantDefaults', () => {
|
||||
it('prefills name/category/spacing/days/vendor from the proposal', () => {
|
||||
const np = newPlantDefaults(packetProposalSchema.parse(proposal))
|
||||
expect(np.name).toBe('Music')
|
||||
expect(np.category).toBe('vegetable')
|
||||
expect(np.spacingCm).toBe(15)
|
||||
expect(np.daysToMaturity).toBe(90)
|
||||
expect(np.vendor).toBe("Johnny's")
|
||||
// No icon/color on a packet — placeholders the user can change later.
|
||||
expect(np.icon).toBe(PACKET_PLANT_ICON)
|
||||
expect(np.color).toBe(PACKET_PLANT_COLOR)
|
||||
})
|
||||
|
||||
it('falls back to a safe category and default spacing when the packet lacks them', () => {
|
||||
const np = newPlantDefaults(
|
||||
packetProposalSchema.parse({
|
||||
packet: { species: 'mystery' },
|
||||
suggestedName: 'mystery',
|
||||
suggestedCategory: 'not-a-real-category',
|
||||
}),
|
||||
)
|
||||
// An unknown category must not leak through — CreatePlant would reject it.
|
||||
expect(np.category).toBe('vegetable')
|
||||
// No printed spacing → the same default the manual form uses.
|
||||
expect(np.spacingCm).toBe(30)
|
||||
expect(np.daysToMaturity).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lotDefaults', () => {
|
||||
it('reads a seed count as "<n> seeds"', () => {
|
||||
const lot = lotDefaults(seedPacketSchema.parse(proposal.packet))
|
||||
expect(lot.quantity).toBe(12)
|
||||
expect(lot.unit).toBe('seeds')
|
||||
expect(lot.vendor).toBe("Johnny's")
|
||||
expect(lot.sku).toBe('G-123')
|
||||
expect(lot.lotCode).toBe('L9')
|
||||
expect(lot.packedForYear).toBe(2026)
|
||||
})
|
||||
|
||||
it('defaults to one packet when no seed count is printed', () => {
|
||||
const lot = lotDefaults(seedPacketSchema.parse({ species: 'basil' }))
|
||||
expect(lot.quantity).toBe(1)
|
||||
expect(lot.unit).toBe('packets')
|
||||
expect(lot.packedForYear).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
// Seed-packet capture client (#81/#102). Two steps, deliberately separate — the
|
||||
// same shape the backend enforces:
|
||||
// POST /seed-lots/scan a packet photo → a PacketProposal (reads only)
|
||||
// POST /seed-lots/from-packet a confirmed proposal → a plant + a seed lot
|
||||
// The scan never writes; a misread can't add anything to the catalog on its own.
|
||||
// Creation happens only from an explicit confirm, with exactly one of an existing
|
||||
// plant (plantId) or a new variety (newPlant).
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { api } from './api'
|
||||
import { PLANT_CATEGORIES, plantSchema, plantsKey, type PlantCategory, type PlantInput } from './plants'
|
||||
import { seedLotSchema, seedLotsKey, type LotUnit, type SeedLotInput } from './seedLots'
|
||||
|
||||
// SeedPacket mirrors internal/vision.SeedPacket: the fields read off a packet.
|
||||
// Every field can be empty or null because the model fills only what's actually
|
||||
// printed — so each has a default and nothing here is required.
|
||||
export const seedPacketSchema = z.object({
|
||||
species: z.string().default(''),
|
||||
variety: z.string().default(''),
|
||||
category: z.string().default(''),
|
||||
vendor: z.string().default(''),
|
||||
sku: z.string().default(''),
|
||||
lotCode: z.string().default(''),
|
||||
packedForYear: z.number().nullable().default(null),
|
||||
daysToMaturity: z.number().nullable().default(null),
|
||||
spacingCm: z.number().nullable().default(null),
|
||||
seedCount: z.number().nullable().default(null),
|
||||
})
|
||||
export type SeedPacket = z.infer<typeof seedPacketSchema>
|
||||
|
||||
// A candidate existing plant the packet might already be, with why it matched so
|
||||
// the UI can show the reason next to it.
|
||||
export const packetMatchSchema = z.object({ plant: plantSchema, reason: z.string() })
|
||||
export type PacketMatch = z.infer<typeof packetMatchSchema>
|
||||
|
||||
// What a scan returns: the read fields, the candidate existing plants (best
|
||||
// first; empty means "probably new"), and prefill hints for a new variety.
|
||||
export const packetProposalSchema = z.object({
|
||||
packet: seedPacketSchema,
|
||||
candidates: z.array(packetMatchSchema).default([]),
|
||||
suggestedName: z.string().default(''),
|
||||
suggestedCategory: z.string().default(''),
|
||||
})
|
||||
export type PacketProposal = z.infer<typeof packetProposalSchema>
|
||||
|
||||
// What a confirm produced: the plant (new or matched) and the created lot.
|
||||
export const packetResultSchema = z.object({
|
||||
plant: plantSchema,
|
||||
lot: seedLotSchema,
|
||||
plantIsNew: z.boolean(),
|
||||
})
|
||||
export type PacketResult = z.infer<typeof packetResultSchema>
|
||||
|
||||
// The lot half of a confirm carries no plantId — the plant comes from the
|
||||
// plantId/newPlant choice, and the server attributes the lot to it.
|
||||
export type SeedLotFields = Omit<SeedLotInput, 'plantId'>
|
||||
|
||||
// A confirmed proposal: exactly one of plantId (attach to an existing plant) or
|
||||
// newPlant (create a variety), plus the lot to record.
|
||||
export interface FromPacketBody {
|
||||
plantId?: number
|
||||
newPlant?: PlantInput
|
||||
lot: SeedLotFields
|
||||
}
|
||||
|
||||
/** Scan a packet photo into a proposal. Multipart upload; the vision call can
|
||||
* take several seconds (the server extends its deadline to 120s), so a `signal`
|
||||
* can be threaded through to abort a slow/hung scan — the caller wires it to a
|
||||
* Cancel button so the dialog is never a trap. Not cached — every photo is a
|
||||
* fresh one-shot. */
|
||||
export function useScanPacket() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ file, signal }: { file: File; signal?: AbortSignal }): Promise<PacketProposal> => {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form, { signal }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Confirm a proposal into a plant + lot. Invalidates both catalogs the new rows
|
||||
* show up in. */
|
||||
export function useCreateFromPacket() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (body: FromPacketBody): Promise<PacketResult> =>
|
||||
packetResultSchema.parse(await api.post('/seed-lots/from-packet', body)),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: plantsKey })
|
||||
void qc.invalidateQueries({ queryKey: seedLotsKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// A packet has no icon or color, so a new variety created from one gets these
|
||||
// placeholders; the user can set a real icon/color later from the plant card.
|
||||
export const PACKET_PLANT_COLOR = '#4a7c3f'
|
||||
export const PACKET_PLANT_ICON = '🌱'
|
||||
// A packet without a printed spacing falls back to this (cm) — the same default
|
||||
// the manual new-plant form uses.
|
||||
const DEFAULT_SPACING_CM = 30
|
||||
|
||||
function isPlantCategory(c: string): c is PlantCategory {
|
||||
return (PLANT_CATEGORIES as readonly string[]).includes(c)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefill a new-variety form from a proposal. Name and category come from the
|
||||
* proposal's suggestions (already validated server-side against the known
|
||||
* categories, but re-checked here); spacing/days/vendor come off the packet.
|
||||
* Color and icon aren't on a packet, so they take the placeholders above.
|
||||
*/
|
||||
export function newPlantDefaults(p: PacketProposal): PlantInput {
|
||||
return {
|
||||
name: p.suggestedName,
|
||||
category: isPlantCategory(p.suggestedCategory) ? p.suggestedCategory : 'vegetable',
|
||||
spacingCm: p.packet.spacingCm ?? DEFAULT_SPACING_CM,
|
||||
color: PACKET_PLANT_COLOR,
|
||||
icon: PACKET_PLANT_ICON,
|
||||
daysToMaturity: p.packet.daysToMaturity,
|
||||
sourceUrl: '',
|
||||
vendor: p.packet.vendor,
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefill the lot fields from the packet. A printed seed count reads naturally as
|
||||
* "<n> seeds"; without one, default to a single packet — the thing you physically
|
||||
* bought — which the user can correct.
|
||||
*/
|
||||
export function lotDefaults(p: SeedPacket): SeedLotFields {
|
||||
const hasCount = p.seedCount != null && p.seedCount > 0
|
||||
const unit: LotUnit = hasCount ? 'seeds' : 'packets'
|
||||
return {
|
||||
vendor: p.vendor,
|
||||
sourceUrl: '',
|
||||
sku: p.sku,
|
||||
lotCode: p.lotCode,
|
||||
purchasedAt: null,
|
||||
packedForYear: p.packedForYear,
|
||||
quantity: hasCount ? (p.seedCount as number) : 1,
|
||||
unit,
|
||||
costCents: null,
|
||||
germinationPct: null,
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, getRouteApi } from '@tanstack/react-router'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||
@@ -12,33 +12,46 @@ import { PlopInspector } from '@/editor/PlopInspector'
|
||||
import { PlantPicker } from '@/editor/PlantPicker'
|
||||
import { Palette } from '@/editor/Palette'
|
||||
import { SeedTray } from '@/editor/SeedTray'
|
||||
import { RecentPlants } from '@/editor/RecentPlants'
|
||||
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
|
||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||
import { EditorHint } from '@/editor/EditorHint'
|
||||
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||||
import { objectDisplayName } from '@/editor/kinds'
|
||||
import { useEditorStore } from '@/editor/store'
|
||||
import { useEditorStore, type EditorMode } from '@/editor/store'
|
||||
import { isCoarsePointer } from '@/editor/shared'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { EditorGarden } from '@/editor/types'
|
||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||
import { AccountMenu } from '@/components/layout/AppShell'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import {
|
||||
toEditorObject,
|
||||
useEnsurePlantInFull,
|
||||
useFillObject,
|
||||
useGardenFull,
|
||||
useGardenSeason,
|
||||
useGardenYears,
|
||||
useUpdateObject,
|
||||
useUpdatePlanting,
|
||||
type FillLayout,
|
||||
} from '@/lib/objects'
|
||||
import { toEditorPlanting } from '@/lib/plantings'
|
||||
import { recentlyPlantedIds, toEditorPlanting } from '@/lib/plantings'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { useCapabilities } from '@/lib/agent'
|
||||
import { useJournalCounts } from '@/lib/journal'
|
||||
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||
import { useSeedTray } from '@/lib/seedTray'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
import { rememberLastGarden, forgetLastGarden } from '@/lib/lastGarden'
|
||||
import { ApiError } from '@/lib/api'
|
||||
|
||||
const routeApi = getRouteApi('/gardens/$gardenId')
|
||||
|
||||
// How many recently-planted chips the Plants-mode quick strip shows — a working
|
||||
// set, not the whole history; the picker covers the long tail.
|
||||
const RECENT_PLANTS_MAX = 8
|
||||
|
||||
export function GardenEditorPage() {
|
||||
const { gardenId } = routeApi.useParams()
|
||||
const gid = Number(gardenId)
|
||||
@@ -55,6 +68,14 @@ export function GardenEditorPage() {
|
||||
const me = useMe()
|
||||
usePageTitle(full.data?.garden.name ?? 'Garden')
|
||||
|
||||
// The garden itself is gone (deleted, or access revoked) — keyed on the LIVE
|
||||
// query, since whether the garden EXISTS doesn't depend on the season being
|
||||
// viewed. Both the bounce effect and the "gone" render message read this one
|
||||
// value, so the promise ("taking you to your gardens…") and the redirect can't
|
||||
// disagree — e.g. a season-view error while the live garden is fine must not
|
||||
// claim a redirect that never fires.
|
||||
const gardenGone = live.error instanceof ApiError && live.error.isNotFound
|
||||
|
||||
const selectedId = useEditorStore((s) => s.selectedId)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
|
||||
@@ -65,6 +86,8 @@ export function GardenEditorPage() {
|
||||
const setRailTab = useEditorStore((s) => s.setRailTab)
|
||||
const journalObjectId = useEditorStore((s) => s.journalObjectId)
|
||||
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
|
||||
const journalPlantingId = useEditorStore((s) => s.journalPlantingId)
|
||||
const setJournalPlantingId = useEditorStore((s) => s.setJournalPlantingId)
|
||||
const journalCounts = useJournalCounts(gid)
|
||||
const capabilities = useCapabilities()
|
||||
const journalTotal = useMemo(
|
||||
@@ -73,8 +96,11 @@ export function GardenEditorPage() {
|
||||
)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
||||
const mode = useEditorStore((s) => s.mode)
|
||||
const setMode = useEditorStore((s) => s.setMode)
|
||||
|
||||
const updatePlanting = useUpdatePlanting(gid)
|
||||
const fillObject = useFillObject(gid)
|
||||
const updateObject = useUpdateObject(gid)
|
||||
const ensurePlant = useEnsurePlantInFull(gid)
|
||||
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
||||
@@ -86,6 +112,10 @@ export function GardenEditorPage() {
|
||||
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
|
||||
const [sharing, setSharing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
// Whether to offer packet scanning — a vision model is configured. Read once
|
||||
// here so all three Plants-mode entry points gate identically.
|
||||
const canScan = !!capabilities.data?.vision
|
||||
const nudgeTimer = useRef<number | null>(null)
|
||||
const nudgeFire = useRef<(() => void) | null>(null)
|
||||
|
||||
@@ -96,6 +126,17 @@ export function GardenEditorPage() {
|
||||
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
||||
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
|
||||
|
||||
// Plants recently placed in THIS garden, newest first (#100) — the quick strip
|
||||
// in Plants mode, so re-planting "more of the same" doesn't need the picker.
|
||||
const recentPlants = useMemo(
|
||||
() =>
|
||||
recentlyPlantedIds(plantings)
|
||||
.slice(0, RECENT_PLANTS_MAX)
|
||||
.map((id) => plantsById.get(id))
|
||||
.filter((p): p is Plant => !!p),
|
||||
[plantings, plantsById],
|
||||
)
|
||||
|
||||
// Role gating, computed before the effects/returns so the nudge handler can use
|
||||
// it. Ownership is the authoritative ownerId==me check.
|
||||
const gd = full.data?.garden
|
||||
@@ -133,6 +174,24 @@ export function GardenEditorPage() {
|
||||
navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true })
|
||||
}, [focusedObjectId, navigate])
|
||||
|
||||
// Remember this garden as the device's last-opened, so `/` resumes here next
|
||||
// visit. Keyed on the live query: which garden EXISTS doesn't depend on the
|
||||
// season being viewed.
|
||||
useEffect(() => {
|
||||
if (live.isSuccess) rememberLastGarden(gid)
|
||||
}, [live.isSuccess, gid])
|
||||
|
||||
// A last garden that 404s (deleted, or access revoked) must not strand the user
|
||||
// on an error screen the `/` resume keeps returning to: forget it (only if it's
|
||||
// this one, so a bad direct link can't wipe a good resume target) and bounce to
|
||||
// the list. Transient errors (500/network) fall through to the retryable screen.
|
||||
useEffect(() => {
|
||||
if (gardenGone) {
|
||||
forgetLastGarden(gid)
|
||||
navigate({ to: '/gardens' })
|
||||
}
|
||||
}, [gardenGone, gid, navigate])
|
||||
|
||||
// If the focused object vanished — deleted, or a stale ?focus id on load — leave
|
||||
// focus mode so the canvas isn't stuck dimmed with no way out.
|
||||
useEffect(() => {
|
||||
@@ -141,24 +200,83 @@ export function GardenEditorPage() {
|
||||
}
|
||||
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
||||
|
||||
// The canvas mode follows focus: inside a bed you're placing Plants, out of it
|
||||
// you're arranging Fixtures — so the bottom strip can't show the object palette
|
||||
// while you're in a bed, or the seed tray while you're not. A panel mode
|
||||
// (journal/assistant) is set explicitly and left alone here. (Inert on desktop,
|
||||
// where the mode bar isn't shown.)
|
||||
useEffect(() => {
|
||||
const cur = useEditorStore.getState().mode
|
||||
if (focusedObjectId != null) {
|
||||
if (cur !== 'journal' && cur !== 'assistant') setMode('plants')
|
||||
} else if (cur === 'plants') {
|
||||
setMode('fixtures')
|
||||
}
|
||||
}, [focusedObjectId, setMode])
|
||||
|
||||
// Selecting anything lands you in the inspector without a click — the rail
|
||||
// must never be something you operate before you can edit. Keyed off the
|
||||
// selected ids rather than a "something is selected" boolean, so selecting a
|
||||
// DIFFERENT object while History is open still brings the inspector forward.
|
||||
// Deselecting drops back out of the inspector but leaves History/Chat open if
|
||||
// that's where you were, since those aren't about the selection.
|
||||
// that's where you were, since those aren't about the selection. Selecting is a
|
||||
// canvas interaction, so it also leaves a panel mode — otherwise closing the
|
||||
// inspector would strand the mode bar on Journal/Assistant with nothing open.
|
||||
useEffect(() => {
|
||||
if (selectedId != null || selectedPlantingId != null) setRailTab('inspector')
|
||||
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null)
|
||||
}, [selectedId, selectedPlantingId, setRailTab])
|
||||
if (selectedId != null || selectedPlantingId != null) {
|
||||
setRailTab('inspector')
|
||||
const s = useEditorStore.getState()
|
||||
if (s.mode === 'journal' || s.mode === 'assistant') {
|
||||
setMode(s.focusedObjectId != null ? 'plants' : 'fixtures')
|
||||
}
|
||||
} else if (useEditorStore.getState().railTab === 'inspector') {
|
||||
setRailTab(null)
|
||||
}
|
||||
}, [selectedId, selectedPlantingId, setRailTab, setMode])
|
||||
|
||||
// Clearing the selection also closes the inspector via the selection effect
|
||||
// above; shared by exitFocus, the mode bar, and the rail's close so the
|
||||
// deselect logic lives in one place.
|
||||
const clearSelection = () => {
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
}
|
||||
|
||||
const exitFocus = () => {
|
||||
setFocusedObject(null)
|
||||
setArmedPlant(null)
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
// The mobile mode bar. Journal/Assistant are panel modes, so they open the rail
|
||||
// as a peek; Fixtures/Plants are canvas modes — a fresh intent — so they close
|
||||
// whatever's in the rail AND drop any lingering selection (which the canvas
|
||||
// would otherwise keep highlighted, e.g. after routing a selection through
|
||||
// Journal and back). Fixtures also steps out of a focused bed.
|
||||
const selectMode = (m: EditorMode) => {
|
||||
setMode(m)
|
||||
if (m === 'journal') {
|
||||
setJournalObjectId(null)
|
||||
setRailTab('journal')
|
||||
} else if (m === 'assistant') {
|
||||
setRailTab('chat')
|
||||
} else {
|
||||
clearSelection()
|
||||
setRailTab(null)
|
||||
if (m === 'fixtures' && focusedObjectId != null) exitFocus()
|
||||
}
|
||||
}
|
||||
|
||||
// Safety for a live capability flip: if the admin turns the assistant off while
|
||||
// it's the active mode, drop back to a canvas mode so the bar isn't stuck on a
|
||||
// tab that no longer exists (and close the now-orphaned chat rail).
|
||||
useEffect(() => {
|
||||
if (!capabilities.data?.agent && useEditorStore.getState().mode === 'assistant') {
|
||||
setMode('fixtures')
|
||||
if (useEditorStore.getState().railTab === 'chat') setRailTab(null)
|
||||
}
|
||||
}, [capabilities.data?.agent, setMode, setRailTab])
|
||||
|
||||
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -174,13 +292,67 @@ export function GardenEditorPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Desktop keyboard nudging: arrows move the selected object/plop 1cm (Shift =
|
||||
// 10cm); the PATCH is debounced ~400ms on key-idle so a held key doesn't spam.
|
||||
// Plops nudge in their object's local frame and clamp to its (local) bounds.
|
||||
// Mounted once, reading live values from nudgeCtx so a data refetch can't
|
||||
// re-subscribe and cancel a pending commit; the pending commit is flushed on
|
||||
// unmount, and a fire only commits if its live value is still present (a drag
|
||||
// that cleared it already committed its own PATCH).
|
||||
// Move the selected object/plop by (dx, dy) cm: apply live geometry instantly,
|
||||
// then commit ONE debounced PATCH so a burst of nudges (a held arrow key, or
|
||||
// repeated taps of the touch pad) doesn't spam the server. Plops clamp to their
|
||||
// object's local bounds. Reads live values from getState/nudgeCtx so it's
|
||||
// correct whichever surface calls it; a commit only fires if its live value is
|
||||
// still present (a drag that cleared it already committed its own PATCH).
|
||||
// Stable across renders (empty deps): both read live values through refs
|
||||
// (nudgeCtx) / getState, never through closed-over props, so a mount-once
|
||||
// consumer (the keydown effect) and a memo-friendly one (NudgePad) both get a
|
||||
// function that stays current without a new identity each render.
|
||||
const commitLater = useCallback((fire: () => void) => {
|
||||
nudgeFire.current = fire
|
||||
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
|
||||
nudgeTimer.current = window.setTimeout(() => {
|
||||
nudgeTimer.current = null
|
||||
const fn = nudgeFire.current
|
||||
nudgeFire.current = null
|
||||
fn?.()
|
||||
}, 400)
|
||||
}, [])
|
||||
|
||||
const nudgeSelected = useCallback((dx: number, dy: number) => {
|
||||
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
|
||||
nudgeCtx.current
|
||||
if (!canNudge) return
|
||||
const s = useEditorStore.getState()
|
||||
if (s.objectDragging) return // don't fight an active pointer drag
|
||||
if (s.selectedId != null) {
|
||||
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
|
||||
if (!base) return
|
||||
s.setLiveObject({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
|
||||
commitLater(() => {
|
||||
const live = useEditorStore.getState().liveObject
|
||||
if (live?.id !== s.selectedId) return
|
||||
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
||||
useEditorStore.getState().setLiveObject(null)
|
||||
})
|
||||
} else if (s.selectedPlantingId != null) {
|
||||
const base =
|
||||
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
|
||||
if (!base) return
|
||||
const obj = objs.find((o) => o.id === base.objectId)
|
||||
let nx = base.xCm + dx
|
||||
let ny = base.yCm + dy
|
||||
if (obj) {
|
||||
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
|
||||
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
|
||||
}
|
||||
s.setLivePlanting({ ...base, xCm: nx, yCm: ny })
|
||||
commitLater(() => {
|
||||
const live = useEditorStore.getState().livePlanting
|
||||
if (live?.id !== s.selectedPlantingId) return
|
||||
up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
||||
useEditorStore.getState().setLivePlanting(null)
|
||||
})
|
||||
}
|
||||
}, [commitLater])
|
||||
|
||||
// Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the
|
||||
// same nudgeSelected the touch pad uses. Mounted once; a pending commit is
|
||||
// flushed on unmount so a nudge in flight isn't lost.
|
||||
useEffect(() => {
|
||||
const DIRS: Record<string, [number, number]> = {
|
||||
ArrowUp: [0, -1],
|
||||
@@ -188,58 +360,16 @@ export function GardenEditorPage() {
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0],
|
||||
}
|
||||
const commitLater = (fire: () => void) => {
|
||||
nudgeFire.current = fire
|
||||
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
|
||||
nudgeTimer.current = window.setTimeout(() => {
|
||||
nudgeTimer.current = null
|
||||
const fn = nudgeFire.current
|
||||
nudgeFire.current = null
|
||||
fn?.()
|
||||
}, 400)
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
|
||||
nudgeCtx.current
|
||||
if (!canNudge) return
|
||||
const dir = DIRS[e.key]
|
||||
if (!dir) return
|
||||
const el = document.activeElement
|
||||
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
|
||||
const s = useEditorStore.getState()
|
||||
if (s.objectDragging) return // don't fight an active pointer drag
|
||||
if (s.selectedId == null && s.selectedPlantingId == null) return
|
||||
e.preventDefault()
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
if (s.selectedId != null) {
|
||||
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
|
||||
if (!base) return
|
||||
e.preventDefault()
|
||||
s.setLiveObject({ ...base, xCm: base.xCm + dir[0] * step, yCm: base.yCm + dir[1] * step })
|
||||
commitLater(() => {
|
||||
const live = useEditorStore.getState().liveObject
|
||||
if (live?.id !== s.selectedId) return // a drag cleared it and committed
|
||||
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
||||
useEditorStore.getState().setLiveObject(null)
|
||||
})
|
||||
} else if (s.selectedPlantingId != null) {
|
||||
const base =
|
||||
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
|
||||
if (!base) return
|
||||
e.preventDefault()
|
||||
const obj = objs.find((o) => o.id === base.objectId)
|
||||
let nx = base.xCm + dir[0] * step
|
||||
let ny = base.yCm + dir[1] * step
|
||||
if (obj) {
|
||||
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
|
||||
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
|
||||
}
|
||||
s.setLivePlanting({ ...base, xCm: nx, yCm: ny })
|
||||
commitLater(() => {
|
||||
const live = useEditorStore.getState().livePlanting
|
||||
if (live?.id !== s.selectedPlantingId) return
|
||||
up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
|
||||
useEditorStore.getState().setLivePlanting(null)
|
||||
})
|
||||
}
|
||||
nudgeSelected(dir[0] * step, dir[1] * step)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
@@ -256,12 +386,20 @@ export function GardenEditorPage() {
|
||||
}, [])
|
||||
|
||||
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden…</p>
|
||||
if (full.isError)
|
||||
if (full.isError) {
|
||||
// Only promise the bounce when the GARDEN is gone (the effect above keys on
|
||||
// the same gardenGone). A season-view error while the live garden is fine is a
|
||||
// different, non-redirecting failure and gets the generic message.
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Alert>Could not load this garden.</Alert>
|
||||
<Alert>
|
||||
{gardenGone
|
||||
? 'That garden is no longer available — taking you to your gardens…'
|
||||
: 'Could not load this garden.'}
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const g = full.data.garden
|
||||
const garden: EditorGarden = {
|
||||
@@ -304,6 +442,14 @@ export function GardenEditorPage() {
|
||||
if (armedPlant?.id === id) setArmedPlant(null)
|
||||
}
|
||||
|
||||
// Fill the whole focused bed with the armed plant at the chosen layout (#100 /
|
||||
// #77) — the UI's way to run the region fill that was agent-only before. Guards
|
||||
// are belt-and-braces: the control only shows with a plant armed in a bed.
|
||||
function fillBed(layout: FillLayout) {
|
||||
if (!canEdit || focusedObject == null || armedPlant == null) return
|
||||
fillObject.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, layout })
|
||||
}
|
||||
|
||||
// The picker hands back the full Plant (from the whole catalog), so use it
|
||||
// directly rather than re-resolving against the garden's referenced-plant map —
|
||||
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
||||
@@ -358,6 +504,13 @@ export function GardenEditorPage() {
|
||||
readOnly={!canEdit}
|
||||
onChangePlant={() => setPicker('change')}
|
||||
onClose={() => selectPlanting(null)}
|
||||
onAddNote={() => {
|
||||
// Parity with the bed inspector: scope the journal to this plop and
|
||||
// open it. The plop stays selected, so the selection effect keeps the
|
||||
// inspector reachable when you switch back.
|
||||
setJournalPlantingId(selectedPlop.id)
|
||||
setRailTab('journal')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
|
||||
@@ -378,6 +531,8 @@ export function GardenEditorPage() {
|
||||
objects={objects}
|
||||
scopeObjectId={journalObjectId}
|
||||
onScopeChange={setJournalObjectId}
|
||||
scopePlantingId={journalPlantingId}
|
||||
onScopePlantingChange={setJournalPlantingId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -401,9 +556,17 @@ export function GardenEditorPage() {
|
||||
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
|
||||
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
|
||||
// the canvas bottom + Fit button under the browser chrome (#85).
|
||||
//
|
||||
// The subtracted band differs by breakpoint because the chrome does. On mobile
|
||||
// the global top bar is hidden here (AppShell), so the only thing outside the
|
||||
// editor is <main>'s py-6 — 3rem, top + bottom. On desktop the header is
|
||||
// present, so keep the original 8rem.
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
||||
<div className="shrink-0 md:w-40">
|
||||
<div className="flex h-[calc(100dvh-3rem)] flex-col gap-3 md:h-[calc(100dvh-8rem)] md:flex-row">
|
||||
{/* Desktop-only control column. On mobile these move to the bottom mode bar
|
||||
+ a slim top strip so the canvas — the point of the screen — isn't shoved
|
||||
into a corner by a stack of controls (#99). */}
|
||||
<div className="hidden shrink-0 md:block md:w-40">
|
||||
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
</h1>
|
||||
@@ -450,38 +613,63 @@ export function GardenEditorPage() {
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
||||
{/* Mobile top strip: the garden identity / season / share that live in the
|
||||
desktop left column, plus the way out. The global header is hidden on
|
||||
mobile here (AppShell), so this leaf is the only route back to the
|
||||
gardens list — it can't be dropped. md:hidden. */}
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Link
|
||||
to="/gardens"
|
||||
aria-label="All gardens"
|
||||
className="-ml-1 shrink-0 rounded-md px-1.5 py-1 text-lg leading-none text-accent-strong outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
🌱
|
||||
</Link>
|
||||
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
</h1>
|
||||
{!canEdit && seasonYear === null && (
|
||||
<span className="shrink-0 rounded-md bg-border/40 px-2 py-0.5 text-xs text-muted">👁 View only</span>
|
||||
)}
|
||||
{years.data && years.data.length > 0 && (
|
||||
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
|
||||
)}
|
||||
{isOwner && (
|
||||
<Button variant="ghost" className="shrink-0 px-2 py-1 text-xs" onClick={() => setSharing(true)}>
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
{/* The global header (and its account menu) is hidden on mobile in the
|
||||
editor, so carry sign-out here — otherwise it's unreachable without
|
||||
leaving the garden. */}
|
||||
{me.data && <AccountMenu displayName={me.data.displayName} />}
|
||||
</div>
|
||||
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
|
||||
{/* Focus toolbar is desktop-only; on mobile its plant tools move to the
|
||||
bottom Plants-mode strip so they don't wrap over the canvas. */}
|
||||
{focusedObject && (
|
||||
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
|
||||
<div className="absolute left-2 top-2 z-20 hidden flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur md:flex">
|
||||
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
||||
← {garden.name}
|
||||
</button>
|
||||
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
|
||||
{canEdit &&
|
||||
(focusedObject.plantable ? (
|
||||
<>
|
||||
<SeedTray
|
||||
trayPlants={trayPlants}
|
||||
armedPlantId={armedPlant?.id ?? null}
|
||||
onArm={armPlant}
|
||||
onRemove={removeFromTrayAndDisarm}
|
||||
onOpenPicker={() => setPicker('place')}
|
||||
/>
|
||||
{armedPlant && (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
{focusedPlops.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||
onClick={() => setClearing(true)}
|
||||
>
|
||||
Clear ({focusedPlops.length})
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
<PlantPlacementTools
|
||||
recentPlants={recentPlants}
|
||||
trayPlants={trayPlants}
|
||||
armedPlant={armedPlant}
|
||||
onArm={armPlant}
|
||||
onRemove={removeFromTrayAndDisarm}
|
||||
onOpenPicker={() => setPicker('place')}
|
||||
onDisarm={() => setArmedPlant(null)}
|
||||
focusedPlopCount={focusedPlops.length}
|
||||
onClear={() => setClearing(true)}
|
||||
onFill={fillBed}
|
||||
filling={fillObject.isPending}
|
||||
canScan={canScan}
|
||||
onScan={() => setScanning(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted">Not plantable</span>
|
||||
))}
|
||||
@@ -489,6 +677,13 @@ export function GardenEditorPage() {
|
||||
)}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
||||
{/* Touch fine-positioning: the keyboard's arrow-nudge has no equivalent
|
||||
on a touch device, and dragging can't hit single-cm precision. Shown
|
||||
on a coarse pointer (same signal as the bigger handles) while
|
||||
something's selected (#104). */}
|
||||
{canEdit && isCoarsePointer && (selectedId != null || selectedPlantingId != null) && (
|
||||
<NudgePad onNudge={nudgeSelected} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty-state hints (non-interactive overlays). */}
|
||||
@@ -500,23 +695,95 @@ export function GardenEditorPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The rail sits between the canvas and the mode bar. On mobile it's an
|
||||
in-flow PEEK (≤50vh), so the garden stays visible above it and the mode
|
||||
bar below (see EditorRail); on desktop it's the right-hand column. */}
|
||||
{railTab && (
|
||||
<EditorRail
|
||||
tabs={railTabs}
|
||||
activeId={railTab}
|
||||
onActivate={setRailTab}
|
||||
// Panel modes want the room; the inspector stays a shorter peek (see the
|
||||
// `tall` prop doc).
|
||||
tall={railTab !== 'inspector'}
|
||||
onClose={() => {
|
||||
// Only the inspector is *about* the selection, so only closing it
|
||||
// deselects; dismissing History leaves the canvas as you had it.
|
||||
// deselects; dismissing a panel leaves the canvas as you had it. Any
|
||||
// panel rail (journal/history/chat) drops back to a canvas mode.
|
||||
if (railTab === 'inspector') {
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
clearSelection()
|
||||
} else {
|
||||
// Back to a canvas mode — Plants if you're still inside a bed, else
|
||||
// Fixtures. (Hardcoding Fixtures here docked the object palette inside
|
||||
// a focused bed.)
|
||||
setMode(focusedObjectId != null ? 'plants' : 'fixtures')
|
||||
}
|
||||
setRailTab(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile bottom: contextual tools for the current canvas mode + the
|
||||
always-visible mode switch bar (#99/#101). md:hidden — desktop uses the
|
||||
left column. The tool strip yields to a rail peek when one is open. */}
|
||||
<div className="shrink-0 md:hidden">
|
||||
{canEdit && !railTab && (mode === 'fixtures' || mode === 'plants') && (
|
||||
<div className="mb-2 min-h-[2.25rem]">
|
||||
{mode === 'fixtures' && <Palette />}
|
||||
{mode === 'plants' &&
|
||||
(focusedObject ? (
|
||||
focusedObject.plantable ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PlantPlacementTools
|
||||
recentPlants={recentPlants}
|
||||
trayPlants={trayPlants}
|
||||
armedPlant={armedPlant}
|
||||
onArm={armPlant}
|
||||
onRemove={removeFromTrayAndDisarm}
|
||||
onOpenPicker={() => setPicker('place')}
|
||||
onDisarm={() => setArmedPlant(null)}
|
||||
focusedPlopCount={focusedPlops.length}
|
||||
onClear={() => setClearing(true)}
|
||||
onFill={fillBed}
|
||||
filling={fillObject.isPending}
|
||||
canScan={!!capabilities.data?.vision}
|
||||
onScan={() => setScanning(true)}
|
||||
/>
|
||||
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||
Done planting
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Reachable via a ?focus= deep link onto a non-plantable object:
|
||||
// say so, and give a way back out (there's no focus toolbar here).
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1 text-xs text-muted">
|
||||
{objectDisplayName(focusedObject)} isn’t plantable.
|
||||
</span>
|
||||
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="px-1 text-xs text-muted">Tap a bed, then “🌱 Plant here” to start planting.</p>
|
||||
{canScan && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-auto px-2 py-1 text-xs"
|
||||
onClick={() => setScanning(true)}
|
||||
>
|
||||
📷 Scan packet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
|
||||
</div>
|
||||
|
||||
{picker && (
|
||||
<PlantPicker
|
||||
unit={garden.unitPref}
|
||||
@@ -527,6 +794,8 @@ export function GardenEditorPage() {
|
||||
|
||||
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
|
||||
|
||||
{scanning && <ScanPacketModal unit={garden.unitPref} onClose={() => setScanning(false)} />}
|
||||
|
||||
{clearing && focusedObject && (
|
||||
<ClearBedModal
|
||||
objectId={focusedObject.id}
|
||||
@@ -539,3 +808,196 @@ export function GardenEditorPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
|
||||
// focus toolbar and the mobile Plants strip so the two can't drift apart.
|
||||
function PlantPlacementTools({
|
||||
recentPlants,
|
||||
trayPlants,
|
||||
armedPlant,
|
||||
onArm,
|
||||
onRemove,
|
||||
onOpenPicker,
|
||||
onDisarm,
|
||||
focusedPlopCount,
|
||||
onClear,
|
||||
onFill,
|
||||
filling,
|
||||
canScan,
|
||||
onScan,
|
||||
}: {
|
||||
recentPlants: Plant[]
|
||||
trayPlants: Plant[]
|
||||
armedPlant: Plant | null
|
||||
onArm: (p: Plant, lot?: SeedLot) => void
|
||||
onRemove: (id: number) => void
|
||||
onOpenPicker: () => void
|
||||
onDisarm: () => void
|
||||
focusedPlopCount: number
|
||||
onClear: () => void
|
||||
onFill: (layout: FillLayout) => void
|
||||
filling: boolean
|
||||
// Scanning a packet adds a variety to the catalog mid-planting; only offered
|
||||
// where a vision model is configured.
|
||||
canScan: boolean
|
||||
onScan: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<RecentPlants plants={recentPlants} armedPlantId={armedPlant?.id ?? null} onArm={onArm} />
|
||||
<SeedTray
|
||||
trayPlants={trayPlants}
|
||||
armedPlantId={armedPlant?.id ?? null}
|
||||
onArm={onArm}
|
||||
onRemove={onRemove}
|
||||
onOpenPicker={onOpenPicker}
|
||||
/>
|
||||
{canScan && (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onScan}>
|
||||
📷 Scan packet
|
||||
</Button>
|
||||
)}
|
||||
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
|
||||
{armedPlant && (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
{focusedPlopCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear ({focusedPlopCount})
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The fill control (#100 / #77): choose a layout, then fill the whole bed with
|
||||
// the armed plant. Clump packs fat blobs for a quick sketch; grid lays out
|
||||
// individual plants in rows you could actually plant from. Defaults to grid,
|
||||
// since "fill this bed" usually means a real planting.
|
||||
function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; busy: boolean }) {
|
||||
const [layout, setLayout] = useState<FillLayout>('grid')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-0.5 text-xs">
|
||||
<span className="inline-flex overflow-hidden rounded-full">
|
||||
{(['clump', 'grid'] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
aria-pressed={layout === l}
|
||||
onClick={() => setLayout(l)}
|
||||
title={l === 'clump' ? 'Fat clumps — a quick sketch' : 'Rows of plants you could plant from'}
|
||||
className={cn(
|
||||
'px-2 py-0.5 font-medium capitalize transition-colors',
|
||||
layout === l ? 'bg-border/70 text-accent-strong' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{l === 'grid' ? 'rows' : l}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
<Button variant="ghost" className="px-2 py-0.5 text-xs" disabled={busy} onClick={() => onFill(layout)}>
|
||||
{busy ? 'Filling…' : 'Fill bed'}
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// On-screen nudge pad (#104): 1cm arrows for the selected object/plop on a touch
|
||||
// device, where the keyboard's arrow-nudge isn't reachable and a drag can't hit
|
||||
// single-cm precision. Rendered only on a coarse pointer (the caller gates it).
|
||||
// Wired to the same nudgeSelected, so it shares the live-then-debounced-PATCH.
|
||||
function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) {
|
||||
const btn =
|
||||
'flex size-10 items-center justify-center rounded-md border border-border bg-surface/90 text-fg ' +
|
||||
'shadow-sm outline-none backdrop-blur transition-colors active:bg-border/70 focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Nudge selection by 1cm"
|
||||
className="absolute bottom-2 left-2 z-20 grid grid-cols-3 grid-rows-3 gap-0.5"
|
||||
>
|
||||
<span />
|
||||
<button type="button" className={btn} aria-label="Nudge up" onClick={() => onNudge(0, -1)}>
|
||||
↑
|
||||
</button>
|
||||
<span />
|
||||
<button type="button" className={btn} aria-label="Nudge left" onClick={() => onNudge(-1, 0)}>
|
||||
←
|
||||
</button>
|
||||
<span className="flex size-10 items-center justify-center text-[0.6rem] font-medium text-muted">1cm</span>
|
||||
<button type="button" className={btn} aria-label="Nudge right" onClick={() => onNudge(1, 0)}>
|
||||
→
|
||||
</button>
|
||||
<span />
|
||||
<button type="button" className={btn} aria-label="Nudge down" onClick={() => onNudge(0, 1)}>
|
||||
↓
|
||||
</button>
|
||||
<span />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
||||
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
||||
// strip. Assistant is dropped when the instance has no model configured.
|
||||
const MODES: { id: EditorMode; label: string; icon: string }[] = [
|
||||
{ id: 'fixtures', label: 'Fixtures', icon: '🛠️' },
|
||||
{ id: 'plants', label: 'Plants', icon: '🌱' },
|
||||
{ id: 'journal', label: 'Journal', icon: '📓' },
|
||||
{ id: 'assistant', label: 'Assistant', icon: '💬' },
|
||||
]
|
||||
|
||||
function ModeBar({
|
||||
mode,
|
||||
onSelect,
|
||||
hasAssistant,
|
||||
canEdit,
|
||||
}: {
|
||||
mode: EditorMode
|
||||
onSelect: (m: EditorMode) => void
|
||||
hasAssistant: boolean
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const modes = MODES.filter((m) => {
|
||||
if (m.id === 'assistant') return hasAssistant
|
||||
// Fixtures/Plants are edit actions — a viewer can't place anything, so the
|
||||
// bar offers only what they can do (read the journal, ask the assistant).
|
||||
if (m.id === 'fixtures' || m.id === 'plants') return canEdit
|
||||
return true
|
||||
})
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Editor mode"
|
||||
className="flex items-stretch justify-around overflow-hidden rounded-xl border border-border bg-surface"
|
||||
>
|
||||
{modes.map((m) => {
|
||||
const active = mode === m.id
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(m.id)}
|
||||
className={cn(
|
||||
'flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors',
|
||||
active ? 'bg-border/60 text-accent-strong' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-lg leading-none">
|
||||
{m.icon}
|
||||
</span>
|
||||
{m.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
||||
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
||||
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
||||
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
||||
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
|
||||
import { PlantPicker } from '@/editor/PlantPicker'
|
||||
import { useCapabilities } from '@/lib/agent'
|
||||
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||
import type { UnitPref } from '@/lib/units'
|
||||
@@ -22,6 +24,7 @@ type Dialog =
|
||||
| { kind: 'duplicate'; plant: Plant }
|
||||
| { kind: 'delete'; plant: Plant }
|
||||
| { kind: 'picker' }
|
||||
| { kind: 'scan' }
|
||||
| { kind: 'addLot'; plant: Plant }
|
||||
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
||||
| { kind: 'deleteLot'; lot: SeedLot }
|
||||
@@ -41,6 +44,7 @@ function loadUnit(): UnitPref {
|
||||
export function PlantsPage() {
|
||||
usePageTitle('Plants')
|
||||
const plants = usePlants()
|
||||
const capabilities = useCapabilities()
|
||||
const seedLots = useSeedLots()
|
||||
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
||||
@@ -72,6 +76,13 @@ export function PlantsPage() {
|
||||
<Button variant="ghost" onClick={() => setDialog({ kind: 'picker' })}>
|
||||
Try the picker
|
||||
</Button>
|
||||
{/* Only where a vision model is configured — otherwise the scan would
|
||||
404 on the vision call, so we don't offer it. */}
|
||||
{capabilities.data?.vision && (
|
||||
<Button variant="ghost" onClick={() => setDialog({ kind: 'scan' })}>
|
||||
Scan a packet
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleUnit}
|
||||
@@ -136,6 +147,7 @@ export function PlantsPage() {
|
||||
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
||||
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
||||
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
||||
{dialog?.kind === 'scan' && <ScanPacketModal unit={unit} onClose={close} />}
|
||||
{dialog?.kind === 'picker' && (
|
||||
<PlantPicker
|
||||
unit={unit}
|
||||
|
||||
+21
-5
@@ -9,15 +9,23 @@ import { AppShell } from '@/components/layout/AppShell'
|
||||
import { NotFound } from '@/components/NotFound'
|
||||
import { RouteError } from '@/components/RouteError'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { RegisterPage } from '@/pages/RegisterPage'
|
||||
import { GardensPage } from '@/pages/GardensPage'
|
||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
||||
import { PublicGardenPage } from '@/pages/PublicGardenPage'
|
||||
import { PlantsPage } from '@/pages/PlantsPage'
|
||||
import { SettingsPage } from '@/pages/SettingsPage'
|
||||
import { meQueryOptions } from '@/lib/auth'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { safeRedirectPath } from '@/lib/redirect'
|
||||
import { getLastGardenId } from '@/lib/lastGarden'
|
||||
import { lazyPage } from '@/lib/lazyPage'
|
||||
|
||||
// Code-split the heavier / deeper routes so a phone on cell data doesn't download
|
||||
// the whole app (notably the canvas editor with its gesture + geometry deps)
|
||||
// before the first screen paints. Login and the gardens list — the entry points —
|
||||
// stay eager to avoid a fallback flash on landing; AppShell wraps the Outlet in a
|
||||
// Suspense boundary for the rest.
|
||||
const GardenEditorPage = lazyPage(() => import('@/pages/GardenEditorPage'), 'GardenEditorPage')
|
||||
const PublicGardenPage = lazyPage(() => import('@/pages/PublicGardenPage'), 'PublicGardenPage')
|
||||
const PlantsPage = lazyPage(() => import('@/pages/PlantsPage'), 'PlantsPage')
|
||||
const SettingsPage = lazyPage(() => import('@/pages/SettingsPage'), 'SettingsPage')
|
||||
const RegisterPage = lazyPage(() => import('@/pages/RegisterPage'), 'RegisterPage')
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
@@ -66,7 +74,15 @@ async function requireAdmin(context: RouterContext, path: string) {
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
// Resume the garden this device was last in, so a returning user lands back in
|
||||
// their plot rather than on the list every time. A stored id that no longer
|
||||
// loads is cleared by the editor and bounces here to /gardens on the next
|
||||
// visit, so this can't trap anyone on a dead garden.
|
||||
beforeLoad: () => {
|
||||
const last = getLastGardenId()
|
||||
if (last != null) {
|
||||
throw redirect({ to: '/gardens/$gardenId', params: { gardenId: String(last) } })
|
||||
}
|
||||
throw redirect({ to: '/gardens' })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -29,6 +29,30 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// The app-wide core goes in ONE cached vendor chunk (rarely changes, so
|
||||
// it survives deploys while the tiny app chunk churns). react + react-dom
|
||||
// + scheduler MUST stay together — splitting react-dom into its own chunk
|
||||
// reorders module init across chunk boundaries and breaks React 19 at
|
||||
// load ("Cannot set 'Activity' of undefined"). Everything else — the
|
||||
// gesture engine, the assistant's markdown renderer + its ecosystem —
|
||||
// rides with whatever imports it, so a lazily-loaded route/feature keeps
|
||||
// it out of the eager first paint. The routes are code-split via
|
||||
// React.lazy (router.tsx), where the real first-paint win is.
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
if (
|
||||
/[/\\]node_modules[/\\](react|react-dom|scheduler|@tanstack|zustand|zod|clsx|tailwind-merge)[/\\]/.test(
|
||||
id,
|
||||
)
|
||||
) {
|
||||
return 'vendor'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user