Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12de25e8f3 | ||
|
|
78ee892b64 | ||
|
|
3b9c9086b6 | ||
|
|
1e0bf16a2a | ||
|
|
79df0df53f | ||
|
|
99798db8f6 | ||
|
|
11e8e1a544 | ||
|
|
7297138630 | ||
|
|
97c8a36bac | ||
|
|
90af03d597 | ||
|
|
8aad2278a0 | ||
|
|
e9df1f05d8 | ||
|
|
2d25b7e28e | ||
|
|
0ab90a01cb | ||
|
|
9ec626302b | ||
|
|
8a27df9e9c | ||
|
|
79f03acea8 | ||
|
|
1b11b2bd62 | ||
|
|
d8003b11fb | ||
|
|
9f434a801a | ||
|
|
14af9502d4 | ||
|
|
283010dccb | ||
|
|
3685308d10 | ||
|
|
afa9288b4b | ||
|
|
c80cf15bf1 | ||
|
|
6a4fd40bc3 | ||
|
|
7c1faa1515 | ||
|
|
1e2b763566 | ||
|
|
757ac7394d | ||
|
|
156b3fd14c | ||
|
|
e3d8e01e5b | ||
|
|
4b348dcbc0 | ||
|
|
d82db48e4b | ||
|
|
20bf7ee03d | ||
|
|
bfc5d9a871 |
@@ -7,11 +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; 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
|
||||
@@ -69,12 +71,14 @@ POST /objects/:id/fill ← hex-pack a region with one plant; region by co
|
||||
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
|
||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
||||
POST /seed-lots/scan ← multipart image → a seed-packet proposal (reads only, no writes)
|
||||
POST /seed-lots/from-packet ← confirmed proposal → a plant (new or existing) + a lot
|
||||
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
||||
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
||||
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
||||
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
||||
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
|
||||
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off
|
||||
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model
|
||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
|
||||
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
|
||||
@@ -124,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.
|
||||
|
||||
|
||||
@@ -70,8 +70,9 @@ The garden assistant reads three more. Setting none of them leaves the assistant
|
||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is off, not broken. This is the one agent value that stays in the environment — it is **never** stored in the database or editable in Settings. |
|
||||
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Default model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. An admin can override this per-instance in **Settings** without a redeploy; a blank Settings value inherits this. |
|
||||
| `PANSY_AGENT_ENABLED` | on when a key is present | Default on/off for the assistant. Also overridable in Settings (which can inherit this default). |
|
||||
| `PANSY_VISION_MODEL` | *(empty)* | Default model for **seed-packet capture** (photograph a packet → it fills in the plant + purchase). A *vision-capable* model (the chat model may not be). Empty = the feature isn't offered. Runs against the same `OLLAMA_CLOUD_API_KEY`, and is overridable in Settings. |
|
||||
|
||||
The model and enabled flag can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — the change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence for the model and enabled flag is **Settings value, if set → env var → built-in default**.
|
||||
The agent model + enabled flag, and the vision model, can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — an agent change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence is **Settings value, if set → env var → built-in default**.
|
||||
|
||||
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -127,8 +127,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -185,6 +185,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
seedLots.GET("/:id", h.getSeedLot)
|
||||
seedLots.PATCH("/:id", h.updateSeedLot)
|
||||
seedLots.DELETE("/:id", h.deleteSeedLot)
|
||||
// Seed-packet capture (#81): scan a photo into a proposal, then create the
|
||||
// plant + lot from the confirmed proposal. scan reads only.
|
||||
seedLots.POST("/scan", h.scanSeedPacket)
|
||||
seedLots.POST("/from-packet", h.createFromPacket)
|
||||
|
||||
// Public, unauthenticated read of a garden by its share token. Deliberately
|
||||
// NOT behind requireAuth: the token is the capability, so a logged-out visitor
|
||||
@@ -204,7 +208,19 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
// so offering the tab must track the live Runner. Reading agent.get() (an atomic
|
||||
// load) means this reflects a settings-driven swap on the very next poll.
|
||||
func (h *handlers) capabilities(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil})
|
||||
// vision advertises whether seed-packet scanning (#81) can be offered — a
|
||||
// configured, resolvable vision model + a key. Read per-request so a settings
|
||||
// change is reflected on the next poll, same as agent.
|
||||
vision := false
|
||||
if vis, err := h.svc.EffectiveVision(c.Request.Context()); err != nil {
|
||||
// A read fault here means the DB is unhappy; report vision off (safe: the
|
||||
// UI just hides a button) but don't do it silently — the same best-effort
|
||||
// settings reads elsewhere log rather than swallow.
|
||||
slog.Error("api: could not resolve vision settings for capabilities", "error", err)
|
||||
} else {
|
||||
vision = vis.Ready()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil, "vision": vision})
|
||||
}
|
||||
|
||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -16,9 +16,12 @@ import (
|
||||
// the buyer — a lot is never shared along with a garden — so every handler here
|
||||
// scopes to the session actor with no garden in the picture.
|
||||
|
||||
// seedLotCreateRequest is the body for POST /seed-lots.
|
||||
type seedLotCreateRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
// seedLotFields is the lot half of a create body — every field EXCEPT which plant
|
||||
// it attaches to. seedLotCreateRequest adds a required plantId; the seed-packet
|
||||
// confirm supplies none (the plant comes from its plantId/newPlant choice), so it
|
||||
// embeds these fields directly. Sharing one struct keeps the two request shapes —
|
||||
// and their validation — from drifting apart.
|
||||
type seedLotFields struct {
|
||||
Vendor string `json:"vendor"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
SKU string `json:"sku"`
|
||||
@@ -32,15 +35,28 @@ type seedLotCreateRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
|
||||
// toInput builds the service input with no plant attribution; callers that know
|
||||
// the plant (the create handler; the packet confirm) set PlantID afterwards.
|
||||
func (f seedLotFields) toInput() service.SeedLotInput {
|
||||
return service.SeedLotInput{
|
||||
PlantID: r.PlantID, Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU,
|
||||
LotCode: r.LotCode, PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear,
|
||||
Quantity: r.Quantity, Unit: r.Unit, CostCents: r.CostCents,
|
||||
GerminationPct: r.GerminationPct, Notes: r.Notes,
|
||||
Vendor: f.Vendor, SourceURL: f.SourceURL, SKU: f.SKU, LotCode: f.LotCode,
|
||||
PurchasedAt: f.PurchasedAt, PackedForYear: f.PackedForYear, Quantity: f.Quantity,
|
||||
Unit: f.Unit, CostCents: f.CostCents, GerminationPct: f.GerminationPct, Notes: f.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// seedLotCreateRequest is the body for POST /seed-lots.
|
||||
type seedLotCreateRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
seedLotFields
|
||||
}
|
||||
|
||||
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
|
||||
in := r.seedLotFields.toInput()
|
||||
in.PlantID = r.PlantID
|
||||
return in
|
||||
}
|
||||
|
||||
// seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
|
||||
// optional, plus the required current version. The nullable columns are
|
||||
// json.RawMessage so an explicit null (clear it) is distinguishable from an
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Seed-packet capture (#81). Two steps, deliberately separate:
|
||||
// POST /seed-lots/scan multipart image → a proposal (reads only)
|
||||
// POST /seed-lots/from-packet confirmed proposal → a plant + lot
|
||||
// The scan never writes; creation happens only from an explicit confirm, so a
|
||||
// misread can't add anything to the catalog on its own.
|
||||
|
||||
// scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at
|
||||
// 25 MiB; this is a little over that for the multipart envelope. A phone photo is
|
||||
// a few MB, so this is generous.
|
||||
const scanUploadLimit = 30 << 20
|
||||
|
||||
// scanReadTimeout is how long we allow the image upload to take. The server's
|
||||
// default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo
|
||||
// on a slow phone connection, so this endpoint extends it — the same
|
||||
// ResponseController mechanism the SSE path uses for writes (#78).
|
||||
const scanReadTimeout = 60 * time.Second
|
||||
|
||||
// scanWriteTimeout extends the write deadline for the same reason. The server's
|
||||
// absolute WriteTimeout (30s) is measured from the start of the request, but this
|
||||
// handler's response can't be written until AFTER a slow upload AND a live vision
|
||||
// call — together easily past 30s. Without this, a successful extraction's
|
||||
// response is silently dropped: the exact failure mode #78 fixed for SSE.
|
||||
const scanWriteTimeout = 120 * time.Second
|
||||
|
||||
// scanSeedPacket reads an uploaded packet photo and returns a proposal.
|
||||
func (h *handlers) scanSeedPacket(c *gin.Context) {
|
||||
// Extend both deadlines for the (potentially large, potentially slow) upload
|
||||
// and the live vision call that follows. Best-effort: if the writer doesn't
|
||||
// support it, the server defaults apply.
|
||||
rc := http.NewResponseController(c.Writer)
|
||||
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout))
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
// A body over scanUploadLimit trips MaxBytesReader — that's 413, not a
|
||||
// malformed request. Everything else here is a genuinely missing/garbled
|
||||
// multipart field.
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||
return
|
||||
}
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field")
|
||||
return
|
||||
}
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
// Opening the parsed upload failed on our side, not the client's.
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not read the uploaded image")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so
|
||||
// everything downstream — including the vision model — only sees a format it
|
||||
// can read. This is where an iPhone HEIC becomes usable.
|
||||
jpeg, _, err := imagenorm.Normalize(f, imagenorm.Options{})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, imagenorm.ErrTooLarge):
|
||||
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||
case errors.Is(err, imagenorm.ErrUnsupported):
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
|
||||
default:
|
||||
// A read or re-encode fault is ours, not bad input.
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not process the uploaded image")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
|
||||
if err != nil {
|
||||
// A missing vision model surfaces as ErrInvalidInput from the service; give
|
||||
// it a clearer message than the generic 400, since the UI shouldn't have
|
||||
// offered the button at all in that case.
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance")
|
||||
return
|
||||
}
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, prop)
|
||||
}
|
||||
|
||||
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
|
||||
// existing plant) or newPlant (create a variety), plus the lot to record. The lot
|
||||
// is seedLotFields — the create body's lot half WITHOUT plantId, since the plant
|
||||
// comes from the plantId/newPlant choice, not the lot body.
|
||||
type fromPacketRequest struct {
|
||||
PlantID *int64 `json:"plantId"`
|
||||
NewPlant *plantCreateRequest `json:"newPlant"`
|
||||
Lot seedLotFields `json:"lot"`
|
||||
}
|
||||
|
||||
// createFromPacket turns a confirmed proposal into a plant + lot.
|
||||
func (h *handlers) createFromPacket(c *gin.Context) {
|
||||
var req fromPacketRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required")
|
||||
return
|
||||
}
|
||||
|
||||
confirm := service.PacketConfirm{
|
||||
PlantID: req.PlantID,
|
||||
Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it
|
||||
}
|
||||
if req.NewPlant != nil {
|
||||
in := req.NewPlant.toInput()
|
||||
confirm.NewPlant = &in
|
||||
}
|
||||
|
||||
res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, res)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// packetEngine builds an engine whose service reads seed packets via the given
|
||||
// canned extractor, so the scan endpoint can be tested without a live model.
|
||||
func packetEngine(t *testing.T, cfg *config.Config, extract func() (vision.SeedPacket, error)) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
svc := service.New(db, cfg, service.WithPacketExtractor(
|
||||
func(context.Context, string, string, []byte) (vision.SeedPacket, error) { return extract() },
|
||||
))
|
||||
return New(cfg, svc)
|
||||
}
|
||||
|
||||
// visionCfg is a config with a vision model + key configured, so packet scanning
|
||||
// is available.
|
||||
func visionCfg() *config.Config {
|
||||
c := localCfg()
|
||||
c.Agent = config.AgentConfig{OllamaCloudAPIKey: "k", VisionModel: "ollama-cloud/vision:cloud"}
|
||||
return c
|
||||
}
|
||||
|
||||
// pngUpload builds a multipart body with a real PNG under the "image" field.
|
||||
func pngUpload(t *testing.T) (body *bytes.Buffer, contentType string) {
|
||||
t.Helper()
|
||||
var img bytes.Buffer
|
||||
if err := png.Encode(&img, image.NewRGBA(image.Rect(0, 0, 32, 24))); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
part, err := w.CreateFormFile("image", "packet.png")
|
||||
if err != nil {
|
||||
t.Fatalf("form file: %v", err)
|
||||
}
|
||||
part.Write(img.Bytes())
|
||||
w.Close()
|
||||
return &buf, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func doMultipart(t *testing.T, r *gin.Engine, path, contentType string, body *bytes.Buffer, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestScanSeedPacketAPI: a multipart image → a proposal, end to end through the
|
||||
// router. The extractor is canned; imagenorm runs for real on the uploaded PNG.
|
||||
func TestScanSeedPacketAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
||||
return vision.SeedPacket{Species: "garlic", Variety: "Music", Category: "vegetable"}, nil
|
||||
})
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
// A matching plant so the proposal has a candidate.
|
||||
createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||
|
||||
body, ct := pngUpload(t)
|
||||
w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct, body, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("scan: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
res := decodeMap(t, w.Body.Bytes())
|
||||
pkt, _ := res["packet"].(map[string]any)
|
||||
if pkt["variety"] != "Music" {
|
||||
t.Errorf("packet variety = %v", pkt["variety"])
|
||||
}
|
||||
if cands, _ := res["candidates"].([]any); len(cands) == 0 {
|
||||
t.Error("expected a candidate match for Music Garlic")
|
||||
}
|
||||
if res["suggestedName"] != "Music" {
|
||||
t.Errorf("suggestedName = %v", res["suggestedName"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanSeedPacketErrorsAPI: no image, unreadable bytes, and vision-not-
|
||||
// configured each get their own clear status.
|
||||
func TestScanSeedPacketErrorsAPI(t *testing.T) {
|
||||
// Vision configured, so we reach imagenorm / the extractor.
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
||||
return vision.SeedPacket{Variety: "X"}, nil
|
||||
})
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
// No file field → 400.
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", "multipart/form-data; boundary=x", bytes.NewBufferString(""), cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("no image = %d, want 400", w.Code)
|
||||
}
|
||||
// A file that isn't an image → 400 (imagenorm rejects it).
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, _ := mw.CreateFormFile("image", "notes.txt")
|
||||
part.Write([]byte("this is not an image"))
|
||||
mw.Close()
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("non-image = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Vision NOT configured → 503, even with a valid image.
|
||||
r2 := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie2 := registerAndCookie(t, r2, "[email protected]")
|
||||
body, ct := pngUpload(t)
|
||||
if w := doMultipart(t, r2, "/api/v1/seed-lots/scan", ct, body, cookie2); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("no vision model = %d, want 503", w.Code)
|
||||
}
|
||||
|
||||
// Unauthenticated → 401.
|
||||
b3, ct3 := pngUpload(t)
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct3, b3, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous scan = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanSeedPacketTooLargeAPI: a body over the multipart cap trips
|
||||
// MaxBytesReader, which must surface as 413 (too large), not 400 (malformed).
|
||||
func TestScanSeedPacketTooLargeAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, _ := mw.CreateFormFile("image", "big.png")
|
||||
// A hair over scanUploadLimit (30 MiB) so MaxBytesReader trips during parsing.
|
||||
part.Write(bytes.Repeat([]byte{0}, (30<<20)+1024))
|
||||
mw.Close()
|
||||
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized upload = %d, want 413", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketAPI: confirm → plant + lot. No model involved, so the full
|
||||
// path runs through the router.
|
||||
func TestCreateFromPacketAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
// New plant + lot.
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"newPlant": map[string]any{"name": "Music Garlic", "category": "vegetable", "color": "#4a7c3f", "icon": "🧄", "spacingCm": 15},
|
||||
"lot": map[string]any{"vendor": "Johnny's", "quantity": 8, "unit": "bulbs"},
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("new plant confirm: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
res := decodeMap(t, w.Body.Bytes())
|
||||
if res["plantIsNew"] != true {
|
||||
t.Errorf("plantIsNew = %v, want true", res["plantIsNew"])
|
||||
}
|
||||
plantObj, _ := res["plant"].(map[string]any)
|
||||
plantID := int64(plantObj["id"].(float64))
|
||||
lotObj, _ := res["lot"].(map[string]any)
|
||||
if int64(lotObj["plantId"].(float64)) != plantID {
|
||||
t.Errorf("lot not attributed to the new plant")
|
||||
}
|
||||
|
||||
// Existing plant + lot.
|
||||
w = doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"plantId": plantID,
|
||||
"lot": map[string]any{"vendor": "Fedco", "quantity": 10, "unit": "bulbs"},
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("existing plant confirm: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if decodeMap(t, w.Body.Bytes())["plantIsNew"] != false {
|
||||
t.Error("plantIsNew should be false for an existing plant")
|
||||
}
|
||||
|
||||
// Both plantId and newPlant → 400.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"plantId": plantID,
|
||||
"newPlant": map[string]any{"name": "X", "category": "vegetable", "color": "#4a7c3f", "icon": "🌱"},
|
||||
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("both plant choices = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Neither → 400.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("neither plant choice = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Unauthenticated → 401.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous confirm = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapabilitiesReportsVision: /capabilities advertises vision only when a
|
||||
// vision model is configured.
|
||||
func TestCapabilitiesReportsVision(t *testing.T) {
|
||||
on := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, on, "[email protected]")
|
||||
w := doJSON(t, on, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
||||
if decodeMap(t, w.Body.Bytes())["vision"] != true {
|
||||
t.Errorf("vision should be true with a model configured: %s", w.Body.String())
|
||||
}
|
||||
|
||||
off := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie2 := registerAndCookie(t, off, "[email protected]")
|
||||
w = doJSON(t, off, http.MethodGet, "/api/v1/capabilities", nil, cookie2)
|
||||
if decodeMap(t, w.Body.Bytes())["vision"] != false {
|
||||
t.Errorf("vision should be false with no model: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -51,26 +51,33 @@ type effectiveView struct {
|
||||
// can be false even when Enabled+HasApiKey are true (an unresolvable model),
|
||||
// which is exactly the case the UI needs to surface.
|
||||
AgentLive bool `json:"agentLive"`
|
||||
// VisionModel is the resolved seed-packet model (DB-over-env). VisionReady is
|
||||
// whether capture can actually be offered (a key and a model).
|
||||
VisionModel string `json:"visionModel"`
|
||||
VisionReady bool `json:"visionReady"`
|
||||
}
|
||||
|
||||
// settingsPayload builds the response, or an error. It does NOT swallow an
|
||||
// EffectiveAgent failure into a misleading empty "effective" view — an empty
|
||||
// EffectiveConfig failure into a misleading empty "effective" view — an empty
|
||||
// view would report no model and no key, which reads as "nothing configured"
|
||||
// rather than "we couldn't read it". Since EffectiveAgent re-reads the same row
|
||||
// rather than "we couldn't read it". Since EffectiveConfig re-reads the same row
|
||||
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
|
||||
// surfacing as a 500, not papering over.
|
||||
// surfacing as a 500, not papering over. It also resolves the agent and vision
|
||||
// views from ONE row read rather than fetching the single-row table twice.
|
||||
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
|
||||
eff, err := h.svc.EffectiveAgent(c.Request.Context())
|
||||
eff, vis, err := h.svc.EffectiveConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
return settingsResponse{}, err
|
||||
}
|
||||
return settingsResponse{
|
||||
Settings: st,
|
||||
Effective: effectiveView{
|
||||
Model: eff.Model,
|
||||
Enabled: eff.Enabled,
|
||||
HasApiKey: eff.APIKey != "",
|
||||
AgentLive: h.agent.get() != nil,
|
||||
Model: eff.Model,
|
||||
Enabled: eff.Enabled,
|
||||
HasApiKey: eff.APIKey != "",
|
||||
AgentLive: h.agent.get() != nil,
|
||||
VisionModel: vis.Model,
|
||||
VisionReady: vis.Ready(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -95,6 +102,7 @@ func (h *handlers) getSettings(c *gin.Context) {
|
||||
type settingsUpdateRequest struct {
|
||||
AgentModel string `json:"agentModel"`
|
||||
AgentEnabled json.RawMessage `json:"agentEnabled"`
|
||||
VisionModel string `json:"visionModel"`
|
||||
Version int64 `json:"version" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -117,6 +125,7 @@ func (h *handlers) updateSettings(c *gin.Context) {
|
||||
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
|
||||
AgentModel: req.AgentModel,
|
||||
AgentEnabled: enabled,
|
||||
VisionModel: req.VisionModel,
|
||||
Version: req.Version,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -74,6 +74,11 @@ type AgentConfig struct {
|
||||
// a key is present, so an instance with no key starts cleanly and simply
|
||||
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
|
||||
Enabled bool
|
||||
// VisionModel is the model that reads a photographed seed packet
|
||||
// (PANSY_VISION_MODEL). Empty by default: the seed-packet capture feature is
|
||||
// only offered when a vision-capable model is configured (in env or Settings)
|
||||
// and a key is present. Passed verbatim to majordomo.Parse, like Model.
|
||||
VisionModel string
|
||||
}
|
||||
|
||||
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
||||
@@ -120,6 +125,9 @@ func Load() *Config {
|
||||
// opt-in, and making people set a second flag to use what they just
|
||||
// configured is a papercut with no upside.
|
||||
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
|
||||
// No default vision model: unlike chat there's no obvious safe default,
|
||||
// and the feature stays off until an admin names one that can see.
|
||||
VisionModel: envStr("PANSY_VISION_MODEL", ""),
|
||||
}
|
||||
|
||||
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
||||
|
||||
@@ -253,9 +253,13 @@ type InstanceSettings struct {
|
||||
// majordomo.Parse, exactly like the env var it shadows.
|
||||
AgentModel string `json:"agentModel"`
|
||||
// AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit.
|
||||
AgentEnabled *bool `json:"agentEnabled"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
AgentEnabled *bool `json:"agentEnabled"`
|
||||
// VisionModel overrides PANSY_VISION_MODEL when non-empty; the model that
|
||||
// reads a photographed seed packet (#81). Empty = feature off unless the env
|
||||
// var names one.
|
||||
VisionModel string `json:"visionModel"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*doma
|
||||
type InstanceSettingsPatch struct {
|
||||
AgentModel string
|
||||
AgentEnabled *bool
|
||||
VisionModel string
|
||||
Version int64
|
||||
}
|
||||
|
||||
@@ -58,16 +59,20 @@ func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, pat
|
||||
return nil, err
|
||||
}
|
||||
model := strings.TrimSpace(patch.AgentModel)
|
||||
// Validate a non-empty spec up front. An empty one is the "inherit env"
|
||||
vision := strings.TrimSpace(patch.VisionModel)
|
||||
// Validate non-empty specs up front. An empty one is the "inherit env"
|
||||
// sentinel and needs no check — the env value was validated at boot.
|
||||
if model != "" {
|
||||
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, model); err != nil {
|
||||
return nil, domain.ErrInvalidInput
|
||||
for _, spec := range []string{model, vision} {
|
||||
if spec != "" {
|
||||
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
||||
AgentModel: model,
|
||||
AgentEnabled: patch.AgentEnabled,
|
||||
VisionModel: vision,
|
||||
Version: patch.Version,
|
||||
})
|
||||
}
|
||||
@@ -95,6 +100,13 @@ func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
||||
if err != nil {
|
||||
return EffectiveAgent{}, err
|
||||
}
|
||||
return s.agentOver(st), nil
|
||||
}
|
||||
|
||||
// agentOver layers a settings row over the env-derived agent defaults. Split out
|
||||
// so EffectiveConfig can resolve agent AND vision from a single row read instead
|
||||
// of fetching the same one-row table twice.
|
||||
func (s *Service) agentOver(st *domain.InstanceSettings) EffectiveAgent {
|
||||
eff := EffectiveAgent{
|
||||
Model: s.cfg.Agent.Model,
|
||||
Enabled: s.cfg.Agent.Enabled,
|
||||
@@ -106,5 +118,51 @@ func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
||||
if st.AgentEnabled != nil {
|
||||
eff.Enabled = *st.AgentEnabled
|
||||
}
|
||||
return eff, nil
|
||||
return eff
|
||||
}
|
||||
|
||||
// EffectiveVision resolves the vision configuration in force for seed-packet
|
||||
// capture (#81): the model from DB-over-env, the key always from env.
|
||||
type EffectiveVision struct {
|
||||
Model string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// Ready reports whether packet capture can be offered: a key and a vision model.
|
||||
// There's no separate enabled flag — configuring a vision model IS enabling it.
|
||||
func (e EffectiveVision) Ready() bool {
|
||||
return e.APIKey != "" && e.Model != ""
|
||||
}
|
||||
|
||||
// EffectiveVision reads the settings row and layers it over the environment.
|
||||
func (s *Service) EffectiveVision(ctx context.Context) (EffectiveVision, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveVision{}, err
|
||||
}
|
||||
return s.visionOver(st), nil
|
||||
}
|
||||
|
||||
// visionOver layers a settings row over the env-derived vision defaults. See
|
||||
// agentOver for why this is split from EffectiveVision.
|
||||
func (s *Service) visionOver(st *domain.InstanceSettings) EffectiveVision {
|
||||
eff := EffectiveVision{
|
||||
Model: s.cfg.Agent.VisionModel,
|
||||
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
||||
}
|
||||
if st.VisionModel != "" {
|
||||
eff.Model = st.VisionModel
|
||||
}
|
||||
return eff
|
||||
}
|
||||
|
||||
// EffectiveConfig resolves the agent AND vision configuration from ONE settings
|
||||
// read, for callers (the settings view) that need both — the single-row table
|
||||
// would otherwise be fetched twice for one response.
|
||||
func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, EffectiveVision, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveAgent{}, EffectiveVision{}, err
|
||||
}
|
||||
return s.agentOver(st), s.visionOver(st), nil
|
||||
}
|
||||
|
||||
+109
-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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// Seed-packet capture (#81): read a photographed packet, propose a plant + lot,
|
||||
// let the user confirm. The two halves are deliberately separate operations:
|
||||
// extraction only READS (a picture in, a proposal out — it can't touch the
|
||||
// garden), and creation happens later, from the confirmed proposal, so a
|
||||
// misread never writes anything on its own.
|
||||
|
||||
// PacketPlantMatch is a candidate existing plant the packet might be, with why it
|
||||
// matched, so the UI can pre-select the likely one and let the user override.
|
||||
type PacketPlantMatch struct {
|
||||
Plant domain.Plant `json:"plant"`
|
||||
// Reason is a short human tag: "exact name", "variety in name", "same species".
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// PacketProposal is what a scan returns: the fields read off the packet, plus
|
||||
// candidate existing plants it might already be. Nothing is created yet.
|
||||
type PacketProposal struct {
|
||||
Packet vision.SeedPacket `json:"packet"`
|
||||
// Candidates are existing plants the packet may match, best first. Empty means
|
||||
// "probably a new variety" — the UI then offers to create one.
|
||||
Candidates []PacketPlantMatch `json:"candidates"`
|
||||
// SuggestedName is the variety (or species) to prefill a new-plant name with.
|
||||
SuggestedName string `json:"suggestedName"`
|
||||
// SuggestedCategory is the packet's category if it's a valid one, for prefill.
|
||||
SuggestedCategory string `json:"suggestedCategory"`
|
||||
}
|
||||
|
||||
// ExtractSeedPacket reads a (JPEG) packet photo and proposes a plant + lot for
|
||||
// the actor to confirm. It needs a configured vision model; with none it returns
|
||||
// ErrInvalidInput (the API layer turns "not configured" into a clear message and
|
||||
// never offers the feature in the first place).
|
||||
//
|
||||
// The extraction runs as the actor only in the sense that the catalog match is
|
||||
// scoped to what they can see; the model call itself has no ACL — it just reads
|
||||
// a picture the actor uploaded.
|
||||
func (s *Service) ExtractSeedPacket(ctx context.Context, actorID int64, jpeg []byte) (*PacketProposal, error) {
|
||||
if len(jpeg) == 0 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
vis, err := s.EffectiveVision(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !vis.Ready() {
|
||||
// No vision model configured — the feature isn't available.
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
packet, err := s.extractPacket(ctx, vis.APIKey, vis.Model, jpeg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plants, err := s.store.ListPlantsForActor(ctx, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PacketProposal{
|
||||
Packet: packet,
|
||||
Candidates: matchPlants(packet, plants),
|
||||
SuggestedName: suggestedName(packet),
|
||||
SuggestedCategory: validCategory(packet.Category),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// suggestedName is the variety if the packet named one, else the species — what
|
||||
// to prefill a new plant's name with. "Music" beats "garlic" when both are read.
|
||||
func suggestedName(p vision.SeedPacket) string {
|
||||
if v := strings.TrimSpace(p.Variety); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(p.Species)
|
||||
}
|
||||
|
||||
// validCategory returns the packet's category if it's one pansy knows, else "".
|
||||
// It reuses plantCategories — the same set CreatePlant validates against — so a
|
||||
// new category can't be accepted by one path and rejected by the other.
|
||||
func validCategory(c string) string {
|
||||
if _, ok := plantCategories[c]; ok {
|
||||
return c
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchPlants ranks existing plants the packet might already be, best first.
|
||||
//
|
||||
// This is the crux of "create both, linked" (#81): getting it wrong makes a
|
||||
// duplicate catalog entry that then splits a variety's seed-lot history across
|
||||
// two rows. So it NEVER decides — it only surfaces candidates for the user to
|
||||
// confirm. Matching is deliberately conservative and name-based (no fuzzy
|
||||
// scoring that could confidently mis-rank): exact variety name, variety appearing
|
||||
// within a plant's name, then same species word. Case-insensitive.
|
||||
func matchPlants(p vision.SeedPacket, plants []domain.Plant) []PacketPlantMatch {
|
||||
variety := strings.ToLower(strings.TrimSpace(p.Variety))
|
||||
species := strings.ToLower(strings.TrimSpace(p.Species))
|
||||
|
||||
// rank: lower is better; keep only matched plants.
|
||||
type scored struct {
|
||||
match PacketPlantMatch
|
||||
rank int
|
||||
}
|
||||
var out []scored
|
||||
seen := map[int64]bool{}
|
||||
add := func(pl domain.Plant, rank int, reason string) {
|
||||
if seen[pl.ID] {
|
||||
return
|
||||
}
|
||||
seen[pl.ID] = true
|
||||
out = append(out, scored{PacketPlantMatch{Plant: pl, Reason: reason}, rank})
|
||||
}
|
||||
|
||||
for _, pl := range plants {
|
||||
name := strings.ToLower(pl.Name)
|
||||
switch {
|
||||
case variety != "" && name == variety:
|
||||
add(pl, 0, "exact name")
|
||||
case variety != "" && strings.Contains(name, variety):
|
||||
add(pl, 1, "variety in name")
|
||||
case species != "" && wordIn(name, species):
|
||||
add(pl, 2, "same species")
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].rank < out[j].rank })
|
||||
|
||||
matches := make([]PacketPlantMatch, len(out))
|
||||
for i, s := range out {
|
||||
matches[i] = s.match
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// wordIn reports whether word appears as a whole space-delimited token in name,
|
||||
// so "garlic" matches "German Garlic" but not "garlicky-thing".
|
||||
func wordIn(name, word string) bool {
|
||||
for _, tok := range strings.Fields(name) {
|
||||
if tok == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PacketConfirm is a user-confirmed proposal to turn into rows.
|
||||
//
|
||||
// Plant selection is explicit: either PlantID names an existing plant to attach
|
||||
// the lot to, or NewPlant carries the fields to create one. Exactly one — the
|
||||
// service refuses both or neither, so an ambiguous confirm can't silently pick.
|
||||
type PacketConfirm struct {
|
||||
// PlantID attaches the lot to an existing plant. Set this XOR NewPlant.
|
||||
PlantID *int64
|
||||
// NewPlant creates a variety. Set this XOR PlantID.
|
||||
NewPlant *PlantInput
|
||||
// Lot is the purchase to record against whichever plant results.
|
||||
Lot SeedLotInput
|
||||
}
|
||||
|
||||
// PacketResult is what a confirm produced.
|
||||
type PacketResult struct {
|
||||
Plant *domain.Plant `json:"plant"`
|
||||
Lot *domain.SeedLot `json:"lot"`
|
||||
PlantIsNew bool `json:"plantIsNew"`
|
||||
}
|
||||
|
||||
// CreateFromPacket turns a confirmed proposal into a plant (new or existing) plus
|
||||
// a seed lot attributed to it. Unlike garden edits these rows aren't in the undo
|
||||
// history — plants and lots are catalog/inventory, created directly — so there is
|
||||
// no change set to wrap; the two creations just happen in sequence.
|
||||
//
|
||||
// If a new plant is created but the lot then fails, the plant is rolled back so
|
||||
// the confirm is all-or-nothing. Otherwise a bad lot (say, an invalid unit) would
|
||||
// strand a half-made catalog entry the user never asked for on its own, and the
|
||||
// error return gives the HTTP caller no handle to it. A just-created plant has no
|
||||
// plantings or lots yet, so the delete is safe; if it somehow can't be undone we
|
||||
// log and still surface the original lot error, not the cleanup one.
|
||||
func (s *Service) CreateFromPacket(ctx context.Context, actorID int64, in PacketConfirm) (*PacketResult, error) {
|
||||
hasID, hasNew := in.PlantID != nil, in.NewPlant != nil
|
||||
if hasID == hasNew {
|
||||
return nil, domain.ErrInvalidInput // exactly one of existing / new
|
||||
}
|
||||
|
||||
res := &PacketResult{}
|
||||
if hasNew {
|
||||
plant, err := s.CreatePlant(ctx, actorID, *in.NewPlant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Plant = plant
|
||||
res.PlantIsNew = true
|
||||
} else {
|
||||
// Attach to an existing plant the actor can see. visiblePlant enforces the
|
||||
// ACL (built-ins + their own); a plant they can't see is ErrNotFound.
|
||||
plant, err := s.visiblePlant(ctx, actorID, *in.PlantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Plant = plant
|
||||
}
|
||||
|
||||
lotIn := in.Lot
|
||||
lotIn.PlantID = res.Plant.ID
|
||||
lot, err := s.CreateSeedLot(ctx, actorID, lotIn)
|
||||
if err != nil {
|
||||
if res.PlantIsNew {
|
||||
// Roll back the plant we just made for this confirm; keep the lot error.
|
||||
if delErr := s.DeletePlant(ctx, actorID, res.Plant.ID); delErr != nil {
|
||||
slog.Error("service: could not roll back plant after packet lot failed",
|
||||
"error", delErr, "plant", res.Plant.ID)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
res.Lot = lot
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
func packet(species, variety, category string) vision.SeedPacket {
|
||||
return vision.SeedPacket{Species: species, Variety: variety, Category: category}
|
||||
}
|
||||
|
||||
func plant(id int64, name string) domain.Plant {
|
||||
return domain.Plant{ID: id, Name: name, Category: domain.CategoryVegetable}
|
||||
}
|
||||
|
||||
// TestMatchPlants pins the catalog-matching heuristic: it surfaces candidates,
|
||||
// best first, and never invents a match — the whole point, since a wrong auto-
|
||||
// match would fragment a variety's seed-lot history across duplicate rows.
|
||||
func TestMatchPlants(t *testing.T) {
|
||||
catalog := []domain.Plant{
|
||||
plant(1, "Garlic"),
|
||||
plant(2, "Music Garlic"),
|
||||
plant(3, "Cherokee Purple"),
|
||||
plant(4, "Basil"),
|
||||
}
|
||||
|
||||
t.Run("exact variety wins, ranked above looser matches", func(t *testing.T) {
|
||||
got := matchPlants(packet("tomato", "Cherokee Purple", "vegetable"), catalog)
|
||||
if len(got) == 0 || got[0].Plant.ID != 3 || got[0].Reason != "exact name" {
|
||||
t.Fatalf("want Cherokee Purple exact first, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("variety within a name, plus same-species, ordered", func(t *testing.T) {
|
||||
// "Music" garlic: "Music Garlic" contains the variety (rank 1); "Garlic"
|
||||
// shares the species word (rank 2).
|
||||
got := matchPlants(packet("garlic", "Music", "vegetable"), catalog)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Plant.ID != 2 || got[0].Reason != "variety in name" {
|
||||
t.Errorf("first = %+v, want Music Garlic / variety in name", got[0])
|
||||
}
|
||||
if got[1].Plant.ID != 1 || got[1].Reason != "same species" {
|
||||
t.Errorf("second = %+v, want Garlic / same species", got[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case-insensitive", func(t *testing.T) {
|
||||
got := matchPlants(packet("", "cherokee purple", ""), catalog)
|
||||
if len(got) == 0 || got[0].Plant.ID != 3 {
|
||||
t.Errorf("case-insensitive exact match failed: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match → empty (a new variety)", func(t *testing.T) {
|
||||
if got := matchPlants(packet("okra", "Clemson Spineless", "vegetable"), catalog); len(got) != 0 {
|
||||
t.Errorf("want no candidates, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("species word boundary, not substring", func(t *testing.T) {
|
||||
// "garlic" should not match a hypothetical "garlicky" — wordIn is token-based.
|
||||
got := matchPlants(packet("garlic", "", ""), []domain.Plant{plant(9, "Garlicky Mustard")})
|
||||
if len(got) != 0 {
|
||||
t.Errorf("substring shouldn't match on species: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// visionTestService builds a service with a configured vision model and a canned
|
||||
// extractor, so ExtractSeedPacket can run with no live model.
|
||||
func visionTestService(t *testing.T, out vision.SeedPacket, extractErr error) (*Service, int64) {
|
||||
t.Helper()
|
||||
cfg := openConfig()
|
||||
cfg.Agent.OllamaCloudAPIKey = "k"
|
||||
cfg.Agent.VisionModel = "ollama-cloud/vision:cloud"
|
||||
s := newTestService(t, cfg)
|
||||
s.extractPacket = func(ctx context.Context, apiKey, model string, jpeg []byte) (vision.SeedPacket, error) {
|
||||
return out, extractErr
|
||||
}
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
return s, owner
|
||||
}
|
||||
|
||||
// TestExtractSeedPacket exercises the orchestration: canned packet → proposal
|
||||
// with catalog candidates + prefill suggestions.
|
||||
func TestExtractSeedPacket(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, packet("garlic", "Music", "vegetable"), nil)
|
||||
// Seed a matching plant.
|
||||
if _, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||
Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed plant: %v", err)
|
||||
}
|
||||
|
||||
prop, err := s.ExtractSeedPacket(ctx, owner, []byte("jpeg-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if prop.Packet.Variety != "Music" {
|
||||
t.Errorf("packet variety = %q", prop.Packet.Variety)
|
||||
}
|
||||
if len(prop.Candidates) == 0 || prop.Candidates[0].Plant.Name != "Music Garlic" {
|
||||
t.Errorf("expected Music Garlic candidate, got %+v", prop.Candidates)
|
||||
}
|
||||
if prop.SuggestedName != "Music" || prop.SuggestedCategory != domain.CategoryVegetable {
|
||||
t.Errorf("suggestions = %q/%q", prop.SuggestedName, prop.SuggestedCategory)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractSeedPacketNeedsVisionModel: with no vision model configured, the
|
||||
// feature is unavailable (ErrInvalidInput), and the extractor is never called.
|
||||
func TestExtractSeedPacketNeedsVisionModel(t *testing.T) {
|
||||
s := newTestService(t, openConfig()) // no vision model, no key
|
||||
called := false
|
||||
s.extractPacket = func(ctx context.Context, _, _ string, _ []byte) (vision.SeedPacket, error) {
|
||||
called = true
|
||||
return vision.SeedPacket{}, nil
|
||||
}
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
if _, err := s.ExtractSeedPacket(context.Background(), owner, []byte("x")); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("extractor was called despite no configured vision model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketNewPlant: a confirm with NewPlant creates the plant and a
|
||||
// lot attributed to it, in one call.
|
||||
func TestCreateFromPacketNewPlant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
|
||||
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
NewPlant: &PlantInput{Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
||||
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: domain.UnitBulbs},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if !res.PlantIsNew || res.Plant.Name != "Music Garlic" {
|
||||
t.Errorf("plant = %+v, isNew=%v", res.Plant, res.PlantIsNew)
|
||||
}
|
||||
if res.Lot == nil || res.Lot.PlantID != res.Plant.ID {
|
||||
t.Errorf("lot not attributed to the new plant: %+v", res.Lot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketExistingPlant: a confirm with PlantID attaches the lot to
|
||||
// the existing plant and creates nothing new.
|
||||
func TestCreateFromPacketExistingPlant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
existing, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||
Name: "Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed plant: %v", err)
|
||||
}
|
||||
|
||||
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
PlantID: &existing.ID,
|
||||
Lot: SeedLotInput{Vendor: "Fedco", Quantity: 10, Unit: domain.UnitBulbs},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if res.PlantIsNew || res.Plant.ID != existing.ID {
|
||||
t.Errorf("should attach to existing plant, got %+v isNew=%v", res.Plant, res.PlantIsNew)
|
||||
}
|
||||
if res.Lot.PlantID != existing.ID {
|
||||
t.Errorf("lot plantId = %d, want %d", res.Lot.PlantID, existing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketRollsBackNewPlantOnLotFailure: if the lot fails after a new
|
||||
// plant was created for the confirm, the plant is rolled back so a bad lot can't
|
||||
// strand a half-made catalog entry the user never asked for on its own.
|
||||
func TestCreateFromPacketRollsBackNewPlantOnLotFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
|
||||
before, err := s.ListPlants(ctx, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("list before: %v", err)
|
||||
}
|
||||
|
||||
// A bogus unit makes CreateSeedLot fail AFTER the plant is created.
|
||||
_, err = s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
NewPlant: &PlantInput{Name: "Rollback Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
||||
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: "furlongs"},
|
||||
})
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("err = %v, want ErrInvalidInput from the bad unit", err)
|
||||
}
|
||||
|
||||
after, err := s.ListPlants(ctx, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("list after: %v", err)
|
||||
}
|
||||
if len(after) != len(before) {
|
||||
t.Errorf("plant count %d → %d: the rolled-back plant was left behind", len(before), len(after))
|
||||
}
|
||||
for _, p := range after {
|
||||
if p.Name == "Rollback Garlic" {
|
||||
t.Errorf("plant %q survived a failed lot; rollback didn't fire", p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketExactlyOne: both or neither of PlantID/NewPlant is refused,
|
||||
// so an ambiguous confirm can't silently pick.
|
||||
func TestCreateFromPacketExactlyOne(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
id := int64(1)
|
||||
for _, in := range []PacketConfirm{
|
||||
{}, // neither
|
||||
{PlantID: &id, NewPlant: &PlantInput{Name: "X"}}, // both
|
||||
} {
|
||||
if _, err := s.CreateFromPacket(ctx, owner, in); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("CreateFromPacket(%+v) err = %v, want ErrInvalidInput", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// timeLayout is the ISO-8601 UTC format used for every full timestamp pansy
|
||||
@@ -41,16 +43,35 @@ type Service struct {
|
||||
// produced by timingHash (fixed salt, no RNG) so it is always present — an
|
||||
// empty one would silently re-open account enumeration.
|
||||
dummyHash string
|
||||
// extractPacket reads a photographed seed packet (#81). Injectable so tests
|
||||
// can supply a canned packet instead of calling a live vision model — the
|
||||
// same reason `now` is injectable. Defaults to vision.Extract.
|
||||
extractPacket func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)
|
||||
}
|
||||
|
||||
// Option customizes a Service at construction. The only current use is injecting
|
||||
// a seed-packet extractor in tests so they don't call a live vision model.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithPacketExtractor overrides how a photographed seed packet is read (#81).
|
||||
// Production uses vision.Extract; a test supplies a canned reader.
|
||||
func WithPacketExtractor(fn func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)) Option {
|
||||
return func(s *Service) { s.extractPacket = fn }
|
||||
}
|
||||
|
||||
// New constructs a Service.
|
||||
func New(st *store.DB, cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
now: time.Now,
|
||||
dummyHash: timingHash(),
|
||||
func New(st *store.DB, cfg *config.Config, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
now: time.Now,
|
||||
dummyHash: timingHash(),
|
||||
extractPacket: vision.Extract,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatTime renders a time as pansy's canonical UTC string.
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// The instance_settings row is seeded by migration 0010 and there is exactly one
|
||||
// (CHECK id = 1), so reads never branch on existence and writes never insert.
|
||||
|
||||
const instanceSettingsColumns = `agent_model, agent_enabled, version, updated_at`
|
||||
const instanceSettingsColumns = `agent_model, agent_enabled, vision_model, version, updated_at`
|
||||
|
||||
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable
|
||||
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
|
||||
@@ -21,7 +21,7 @@ func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
|
||||
out domain.InstanceSettings
|
||||
enabled sql.NullInt64
|
||||
)
|
||||
if err := s.Scan(&out.AgentModel, &enabled, &out.Version, &out.UpdatedAt); err != nil {
|
||||
if err := s.Scan(&out.AgentModel, &enabled, &out.VisionModel, &out.Version, &out.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if enabled.Valid {
|
||||
@@ -60,12 +60,12 @@ func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSetti
|
||||
}
|
||||
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
||||
`UPDATE instance_settings
|
||||
SET agent_model = ?, agent_enabled = ?,
|
||||
SET agent_model = ?, agent_enabled = ?, vision_model = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 1 AND version = ?
|
||||
RETURNING `+instanceSettingsColumns,
|
||||
s.AgentModel, enabled, s.Version,
|
||||
s.AgentModel, enabled, s.VisionModel, s.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
current, gerr := d.GetInstanceSettings(ctx)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Vision model setting (#81): the model that reads a photographed seed packet.
|
||||
--
|
||||
-- Separate from agent_model because it's a different capability — extracting
|
||||
-- structured fields from an image needs a vision-capable model, which the chat
|
||||
-- model may not be. Same "inherit from env unless set" contract as agent_model:
|
||||
-- '' falls back to PANSY_VISION_MODEL, then the feature is simply not offered.
|
||||
--
|
||||
-- Like agent_model, the API KEY is NOT stored here — the vision model runs
|
||||
-- against the same OLLAMA_CLOUD_API_KEY from the environment.
|
||||
ALTER TABLE instance_settings ADD COLUMN vision_model TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,77 @@
|
||||
// Package vision reads a photographed seed packet into structured fields (#81).
|
||||
//
|
||||
// It is one-shot structured extraction, NOT an agent loop: majordomo.Generate[T]
|
||||
// derives a JSON schema from the SeedPacket struct, hands the image to a vision
|
||||
// model, and unmarshals the reply into SeedPacket. Because it can't call a tool,
|
||||
// it can't touch the garden — it only reads a picture and returns data, which the
|
||||
// service then turns into a plant + lot after the user confirms.
|
||||
package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||
)
|
||||
|
||||
// SeedPacket is what a vision model reads off a packet. The json/description/enum
|
||||
// tags drive the schema majordomo.Generate derives; pointer fields are nullable,
|
||||
// so a field the packet doesn't print comes back nil rather than a made-up zero.
|
||||
//
|
||||
// These are the packet's PRINTED facts. Mapping them onto a pansy Plant + SeedLot
|
||||
// (and deciding whether the variety is one already in the catalog) is the
|
||||
// service's job, not the model's.
|
||||
type SeedPacket struct {
|
||||
Species string `json:"species" description:"the plant species in plain words, e.g. tomato, garlic, basil"`
|
||||
Variety string `json:"variety" description:"the cultivar or variety name, e.g. Cherokee Purple; empty if the packet only names a species"`
|
||||
Category string `json:"category" enum:"vegetable,herb,flower,fruit,tree_shrub,cover" description:"the single best-fit category"`
|
||||
Vendor string `json:"vendor" description:"the seed company, e.g. Johnny's Selected Seeds"`
|
||||
SKU string `json:"sku" description:"the vendor's item/product number, if printed"`
|
||||
LotCode string `json:"lotCode" description:"the lot or batch code, if printed"`
|
||||
PackedForYear *int `json:"packedForYear" description:"the 'packed for' or 'sell by' year, if printed"`
|
||||
DaysToMaturity *int `json:"daysToMaturity" description:"days to maturity/harvest, if printed"`
|
||||
SpacingCM *float64 `json:"spacingCm" description:"recommended in-row spacing in CENTIMETERS; convert if the packet uses inches"`
|
||||
SeedCount *int `json:"seedCount" description:"approximate seed count in the packet, if printed"`
|
||||
}
|
||||
|
||||
// extractPrompt tells the model the conventions it can't guess: centimeters, and
|
||||
// that a missing field must be left empty rather than invented.
|
||||
const extractPrompt = `You are reading a photograph of a seed packet. Extract only what is actually printed on it.
|
||||
Rules:
|
||||
- Spacing must be in CENTIMETERS. If the packet gives inches, convert (1 in = 2.54 cm).
|
||||
- If a field is not printed on the packet, leave it empty or null. Do not guess or fill from general knowledge.
|
||||
- "variety" is the cultivar name (e.g. "Cherokee Purple"); "species" is the plain plant name (e.g. "tomato").`
|
||||
|
||||
// Extract runs one vision extraction: it resolves the model spec against pansy's
|
||||
// registry, sends the JPEG with the prompt, and returns the parsed SeedPacket.
|
||||
// The image bytes should already be normalized to JPEG (see internal/imagenorm).
|
||||
//
|
||||
// It makes a live model call, so callers give it a bounded context.
|
||||
func Extract(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (SeedPacket, error) {
|
||||
if len(jpeg) == 0 {
|
||||
return SeedPacket{}, fmt.Errorf("vision: empty image")
|
||||
}
|
||||
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||
if err != nil {
|
||||
return SeedPacket{}, err
|
||||
}
|
||||
return generate(ctx, model, jpeg)
|
||||
}
|
||||
|
||||
// generate makes the actual one-shot call against an already-resolved model. It
|
||||
// is split from Extract on purpose: the hermetic test drives THIS function with a
|
||||
// fake model, so the prompt, the derived schema and the image part it builds are
|
||||
// the real ones the live path uses — not a hand-copied double that could drift.
|
||||
func generate(ctx context.Context, model llm.Model, jpeg []byte) (SeedPacket, error) {
|
||||
return majordomo.Generate[SeedPacket](ctx, model, majordomo.Request{
|
||||
Messages: []majordomo.Message{
|
||||
majordomo.UserParts(
|
||||
majordomo.Text(extractPrompt),
|
||||
majordomo.Image("image/jpeg", jpeg),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
)
|
||||
|
||||
// tinyJPEG returns a real, sniffable JPEG. The chain runs media.Normalize before
|
||||
// the provider, which checks the image's magic bytes, so a string literal won't
|
||||
// do — the bytes must actually be a JPEG.
|
||||
func tinyJPEG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// TestExtractParsesModelJSON is the hermetic proof that the extraction path works
|
||||
// end to end without a live model: a fake vision model returns canned packet JSON
|
||||
// and Generate[SeedPacket] unmarshals it into the struct, image and schema
|
||||
// included.
|
||||
func TestExtractParsesModelJSON(t *testing.T) {
|
||||
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||
fp := fake.New("fp") // default caps advertise structured output + images
|
||||
reg.RegisterProvider(fp)
|
||||
fp.Enqueue("vision", fake.Reply(`{
|
||||
"species": "garlic",
|
||||
"variety": "Music",
|
||||
"category": "vegetable",
|
||||
"vendor": "Johnny's",
|
||||
"sku": "2761",
|
||||
"lotCode": "L-42",
|
||||
"packedForYear": 2026,
|
||||
"daysToMaturity": 240,
|
||||
"spacingCm": 15,
|
||||
"seedCount": 8
|
||||
}`))
|
||||
|
||||
m, err := reg.Parse("fp/vision")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
got, err := generate(context.Background(), m, tinyJPEG(t))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got.Variety != "Music" || got.Species != "garlic" || got.Category != "vegetable" {
|
||||
t.Errorf("unexpected packet: %+v", got)
|
||||
}
|
||||
if got.SpacingCM == nil || *got.SpacingCM != 15 {
|
||||
t.Errorf("spacingCm = %v, want 15", got.SpacingCM)
|
||||
}
|
||||
if got.PackedForYear == nil || *got.PackedForYear != 2026 {
|
||||
t.Errorf("packedForYear = %v, want 2026", got.PackedForYear)
|
||||
}
|
||||
|
||||
// The image and the derived schema really reached the model.
|
||||
call := fp.Calls()[0]
|
||||
if call.Request.SchemaName != "seedpacket" {
|
||||
t.Errorf("schema name = %q, want seedpacket", call.Request.SchemaName)
|
||||
}
|
||||
var sawImage bool
|
||||
for _, p := range call.Request.Messages[0].Parts {
|
||||
if _, ok := p.(llm.ImagePart); ok {
|
||||
sawImage = true
|
||||
}
|
||||
}
|
||||
if !sawImage {
|
||||
t.Error("the image part didn't reach the model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractLeavesMissingFieldsNil: a packet that only prints a species comes
|
||||
// back with nil pointers for the numbers, not invented zeros — the whole reason
|
||||
// the numeric fields are pointers.
|
||||
func TestExtractLeavesMissingFieldsNil(t *testing.T) {
|
||||
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||
fp := fake.New("fp")
|
||||
reg.RegisterProvider(fp)
|
||||
fp.Enqueue("vision", fake.Reply(`{"species":"basil","category":"herb"}`))
|
||||
m, _ := reg.Parse("fp/vision")
|
||||
|
||||
got, err := generate(context.Background(), m, tinyJPEG(t))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got.SpacingCM != nil || got.DaysToMaturity != nil || got.PackedForYear != nil || got.SeedCount != nil {
|
||||
t.Errorf("missing numeric fields should be nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRejectsEmptyImage: no bytes, no call.
|
||||
func TestExtractRejectsEmptyImage(t *testing.T) {
|
||||
if _, err := Extract(context.Background(), "k", "fp/vision", nil); err == nil {
|
||||
t.Error("Extract accepted an empty image")
|
||||
}
|
||||
}
|
||||
@@ -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,59 @@ 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 })
|
||||
const showBottomNav = !!user && !inEditor && !inPublicGarden
|
||||
|
||||
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">
|
||||
<header className="sticky top-0 z-20 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">
|
||||
<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 +85,132 @@ export function AppShell() {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
|
||||
<Outlet />
|
||||
<main
|
||||
className={cn(
|
||||
'mx-auto w-full max-w-5xl flex-1 px-4 py-6',
|
||||
// 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. */
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
|
||||
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
|
||||
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
|
||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/**
|
||||
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
|
||||
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
||||
@@ -27,12 +34,57 @@ export function Modal({
|
||||
busyRef.current = busy
|
||||
|
||||
useEffect(() => {
|
||||
cardRef.current?.focus()
|
||||
const card = cardRef.current
|
||||
// Remember who opened the dialog so focus can return there on close —
|
||||
// otherwise it lands on <body> and a keyboard user loses their place.
|
||||
const opener = document.activeElement as HTMLElement | null
|
||||
card?.focus()
|
||||
|
||||
const focusable = () =>
|
||||
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !busyRef.current) onCloseRef.current()
|
||||
if (e.key === 'Escape' && !busyRef.current) {
|
||||
onCloseRef.current()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const items = focusable()
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
card?.focus()
|
||||
return
|
||||
}
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
// If focus is NOT inside the dialog, pull it back in rather than let Tab
|
||||
// escape. This is the robust case that covers focus having fallen to
|
||||
// <body> — a control that was removed (ShareGardenModal's remove-share
|
||||
// button) or disabled while busy — as well as any externally-stolen focus.
|
||||
if (!card || !card.contains(active)) {
|
||||
e.preventDefault()
|
||||
;(e.shiftKey ? last : first).focus()
|
||||
return
|
||||
}
|
||||
if (e.shiftKey && (active === first || active === card)) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
// Restore focus to the opener only if it's still in the document — the
|
||||
// delete/clear flows this trap targets often remove the element that
|
||||
// opened the dialog (a garden card, a plop row). A disconnected node's
|
||||
// focus() silently no-ops and leaves focus on <body>, so fall through to
|
||||
// that case explicitly rather than pretend it worked.
|
||||
if (opener && opener.isConnected) opener.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,9 +17,16 @@ interface ToastState {
|
||||
|
||||
let nextId = 1
|
||||
|
||||
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
|
||||
// the stack without bound and push older ones off-screen. Cap it: keep the most
|
||||
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
|
||||
// happened — is always visible.
|
||||
const MAX_TOASTS = 4
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })),
|
||||
push: (message, tone = 'info') =>
|
||||
set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }].slice(-MAX_TOASTS) })),
|
||||
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
}))
|
||||
|
||||
@@ -32,21 +39,34 @@ export const toast = {
|
||||
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
||||
function ToastItem({ item }: { item: Toast }) {
|
||||
const dismiss = useToastStore((s) => s.dismiss)
|
||||
const isError = item.tone === 'error'
|
||||
useEffect(() => {
|
||||
// Error toasts are the primary report that a mutation failed, so they do NOT
|
||||
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
||||
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
||||
if (isError) return
|
||||
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [item.id, dismiss])
|
||||
}, [item.id, dismiss, isError])
|
||||
return (
|
||||
<div
|
||||
role={item.tone === 'error' ? 'alert' : 'status'}
|
||||
role={isError ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
item.tone === 'error'
|
||||
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
isError
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
: 'border-border bg-surface text-fg',
|
||||
)}
|
||||
>
|
||||
{item.message}
|
||||
<span className="flex-1">{item.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(item.id)}
|
||||
aria-label="Dismiss"
|
||||
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,14 @@ 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 ≤50vh 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.
|
||||
*/
|
||||
|
||||
export interface RailTab {
|
||||
@@ -47,9 +50,13 @@ 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.
|
||||
'flex max-h-[50vh] min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
||||
'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">
|
||||
|
||||
@@ -235,7 +235,13 @@ export function GardenCanvas({
|
||||
className="h-full w-full select-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={onCanvasPointerDown}
|
||||
// role="application" tells a screen reader this is an interactive canvas
|
||||
// to operate, not a document to read linearly. The <title> names it, and
|
||||
// objects inside are individually focusable buttons (see ObjectShape).
|
||||
role="application"
|
||||
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
|
||||
>
|
||||
<title>{garden.name} garden layout</title>
|
||||
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
||||
{drawnGridCm != null && (
|
||||
<>
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
import type { EditorObject } from './types'
|
||||
import { objectDisplayName } from './kinds'
|
||||
|
||||
// Shared styling for the small From/To date inputs, so the two stay in step and
|
||||
// don't drift from each other.
|
||||
const dateInputClass =
|
||||
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||
|
||||
/**
|
||||
* The garden's journal: write an entry, read the season back.
|
||||
*
|
||||
@@ -42,7 +47,15 @@ export function JournalPanel({
|
||||
scopeObjectId: number | null
|
||||
onScopeChange: (id: number | null) => void
|
||||
}) {
|
||||
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
|
||||
// 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 filter = {
|
||||
...(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
|
||||
@@ -70,6 +83,41 @@ export function JournalPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<label className="flex items-center gap-1">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
{(from || to) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFrom('')
|
||||
setTo('')
|
||||
}}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<Composer
|
||||
gardenId={gardenId}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, type PointerEvent } from 'react'
|
||||
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
import { objectTransform } from './shared'
|
||||
import { kindDef, objectDisplayName } from './kinds'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const DEFAULT_FILL = '#8a8a8a'
|
||||
@@ -57,11 +58,50 @@ export const ObjectShape = memo(function ObjectShape({
|
||||
onSelect(object.id)
|
||||
}
|
||||
|
||||
// Keyboard path into selection (#84): the arrow-key nudge handler already
|
||||
// exists but only ever acted on a pointer selection, so it was unreachable
|
||||
// without a mouse. Enter/Space on a focused object selects it, which is the
|
||||
// step that was missing.
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSelect(object.id)
|
||||
}
|
||||
}
|
||||
|
||||
const stroke = selected ? '#2f7a3e' : '#00000033'
|
||||
const strokeWidth = selected ? 2 : 1
|
||||
|
||||
// A concise accessible name: the object's label plus its kind's canonical
|
||||
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
|
||||
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
|
||||
// dimensions aren't included; they need the garden's unit context this
|
||||
// component doesn't hold, so they're a follow-up.
|
||||
const kindLabel = kindDef(object.kind)?.label ?? object.kind
|
||||
const label = `${objectDisplayName(object)}, ${kindLabel}`
|
||||
|
||||
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
|
||||
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
|
||||
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
|
||||
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
|
||||
// rather than React state because onFocus on an SVG <g> is unreliable and a
|
||||
// presentation attribute is overridden by any CSS rule.
|
||||
return (
|
||||
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
|
||||
<g
|
||||
className="object-shape"
|
||||
transform={objectTransform(object)}
|
||||
onPointerDown={handleDown}
|
||||
onKeyDown={handleKey}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
|
||||
// toggle is what aria-pressed means). aria-current marks it as the active
|
||||
// item among the objects. Omitted, not "false", when unselected.
|
||||
aria-current={selected || undefined}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse
|
||||
cx={0}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -82,6 +97,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 }),
|
||||
|
||||
@@ -129,5 +147,6 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
railTab: null,
|
||||
seasonYear: null,
|
||||
journalObjectId: null,
|
||||
mode: DEFAULT_MODE,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -160,6 +160,15 @@ export async function streamChat(
|
||||
handlers.onError('Could not reach the server.')
|
||||
return
|
||||
}
|
||||
if (res.status === 401) {
|
||||
// Session expired mid-conversation (#85). Reporting this as "the assistant is
|
||||
// unavailable" would send the user chasing a config problem that isn't there.
|
||||
// Send them to sign in again, preserving where they were.
|
||||
handlers.onError('Your session has expired — please sign in again.')
|
||||
const back = encodeURIComponent(location.pathname + location.search)
|
||||
window.location.assign(`/login?redirect=${back}`)
|
||||
return
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
// 503 is the assistant being turned off at runtime (#79) — the route exists,
|
||||
// there's just no model behind it. Distinct from a 404, which would mean the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -12,33 +12,43 @@ 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 { 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 { cn } from '@/lib/cn'
|
||||
import type { EditorGarden } from '@/editor/types'
|
||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||
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 +65,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)
|
||||
@@ -73,8 +91,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)
|
||||
@@ -96,6 +117,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 +165,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 +191,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) {
|
||||
@@ -256,12 +365,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 +421,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
|
||||
@@ -398,9 +523,15 @@ 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).
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
||||
<div className="shrink-0 md:w-40">
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 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>
|
||||
@@ -447,38 +578,48 @@ 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. md:hidden. */}
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<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>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted">Not plantable</span>
|
||||
))}
|
||||
@@ -497,6 +638,9 @@ 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}
|
||||
@@ -504,16 +648,69 @@ export function GardenEditorPage() {
|
||||
onActivate={setRailTab}
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
) : (
|
||||
<p className="px-1 text-xs text-muted">Tap a bed, then “🌱 Plant here” to start planting.</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
|
||||
</div>
|
||||
|
||||
{picker && (
|
||||
<PlantPicker
|
||||
unit={garden.unitPref}
|
||||
@@ -536,3 +733,150 @@ 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,
|
||||
}: {
|
||||
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
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<RecentPlants plants={recentPlants} armedPlantId={armedPlant?.id ?? null} onArm={onArm} />
|
||||
<SeedTray
|
||||
trayPlants={trayPlants}
|
||||
armedPlantId={armedPlant?.id ?? null}
|
||||
onArm={onArm}
|
||||
onRemove={onRemove}
|
||||
onOpenPicker={onOpenPicker}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function PublicGardenPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
|
||||
+53
-5
@@ -1,3 +1,4 @@
|
||||
import { lazy, type ComponentType } from 'react'
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
createRoute,
|
||||
@@ -9,15 +10,54 @@ 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'
|
||||
|
||||
// Lazily load a page 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 RouteError's "Try again" (router.invalidate) 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.
|
||||
function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
|
||||
return lazy(async () => {
|
||||
try {
|
||||
const mod = await load()
|
||||
try {
|
||||
sessionStorage.removeItem('pansy:chunk-reload')
|
||||
} catch {
|
||||
/* storage unavailable — fine */
|
||||
}
|
||||
return { default: mod[name] as ComponentType }
|
||||
} catch (err) {
|
||||
try {
|
||||
if (!sessionStorage.getItem('pansy:chunk-reload')) {
|
||||
sessionStorage.setItem('pansy:chunk-reload', '1')
|
||||
window.location.reload()
|
||||
return await new Promise<{ default: ComponentType }>(() => {}) // 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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 +106,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' })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Keyboard focus on a canvas object (#84). :focus-visible shows the ring for
|
||||
keyboard focus but not a mouse click; the dashed accent ring distinguishes
|
||||
"focused" from the solid ring that marks "selected". A CSS rule overrides
|
||||
the shape's inline stroke presentation attributes. */
|
||||
.object-shape {
|
||||
outline: none;
|
||||
}
|
||||
.object-shape:focus-visible :is(rect, ellipse) {
|
||||
stroke: var(--color-accent-strong);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
||||
|
||||
@@ -29,6 +29,27 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// One vendor chunk for ALL node_modules: it's rarely-changing, so it
|
||||
// caches across app deploys while the tiny app chunk churns. Kept as a
|
||||
// SINGLE chunk on purpose — splitting react-dom/scheduler into their own
|
||||
// chunk reorders their module init across chunk boundaries and breaks
|
||||
// React 19 at load ("Cannot set 'Activity' of undefined"). The heavy
|
||||
// routes are code-split separately via React.lazy (router.tsx), which is
|
||||
// where the real first-paint win is.
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
// Editor-only libs (the gesture engine) ride with the lazy editor
|
||||
// chunk instead of the eager vendor one, so the first paint doesn't
|
||||
// pay for code only the canvas needs. Everything else — react, tanstack
|
||||
// and the rest — stays in ONE vendor chunk; splitting react-dom out
|
||||
// reorders its init across chunks and breaks React 19 at load.
|
||||
if (id.includes('@use-gesture')) return undefined
|
||||
return 'vendor'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user