3 Commits
Author SHA1 Message Date
steveandClaude Opus 4.8 4597978034 Address Gadfly review on the seed shelf UI
Build image / build-and-push (push) Successful in 16s
The correctness finding is a real bug and the interesting one: arming a plant
from the Seed Tray never attributed a seed lot, not even in the unambiguous
single-lot case. The tray is the QUICKEST way to place a plant, so the fastest
path was the one that silently lost which packet the seed came from — and it
would have looked fine, because attribution is invisible until you go looking at
a remaining count that's wrong. armPlant now does the same single-lot
auto-attribution the picker does. Several lots stays unattributed rather than
guessing; the picker is where that choice belongs.

A single exhausted lot was also indistinguishable from having no lot, because
the "available" filter dropped it before the count was taken. That silently
dropped attribution with no signal. attributableLots now offers exhausted lots
too, just not first: the count records what was written down, not a fact about
the packet, so refusing to attribute a planting because the number says zero
makes a wrong number harder to correct rather than easier. It's also now one
rule in one place instead of the same filter written twice.

The seed lot form had no 409 handling, alone among the version-guarded forms
here. A concurrent edit made you retype everything; it now rebases onto the
current row and says so, like the garden and plant forms do.

Deduplication: the outbound-link anchor was duplicated verbatim between the
plant card and the lot rows, including the security-relevant rel attribute — now
one SourceLink that re-checks the URL and renders nothing when it isn't safe, so
callers can't forget to guard. remainingLabel had its own copy of the
unit-agreement rule that summarizeLots already owns; it now calls it.
formatQuantity moved out of a component file into lib/seedLots.ts with the other
formatters.

STATE_CLASS.over and .low were identical. "Over-planted" is a discrepancy to
look at, not a shortage to act on, so it now reads as its own warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 02:02:52 -04:00
steveandClaude Opus 4.8 5189cb27a1 Seed shelf UI: source links, lots, and what's left (#51)
question gets asked twice — at the seed rack in January, and standing over a bed
in May — so the answer shows up in both places.

On the plant card: vendor and a source link, plus a seed count that sits with
the actions rather than in the body, because it's what you scan down a list of
twenty packets deciding what to order. Expanding shows each lot separately with
vendor, dates, germination, cost, and its own remaining count — two lots of one
variety are two rows with independent numbers, which is the whole reason
inventory lives on the purchase rather than on the plant.

State is encoded in form as well as number: a coloured chip that says "low" or
"empty" in words, and a proportional bar. A bare number doesn't survive being
skimmed, and skimming is the actual use. "Over-planted" is a state of its own
rather than being clamped to zero — planting more than you recorded buying
happens, and hiding it defeats the point of tracking.

In the plant picker, remaining shows next to the plant you're about to place,
and a plant with several lots asks which packet it came out of. One lot
auto-attributes and zero lots stays silent: forcing that question on every
placement is how a nicety becomes an obstacle. The armed lot rides along with
the armed plant, so repeat placement keeps attributing without asking again.

External URLs are re-checked client-side before being rendered as links, and
carry rel="noopener noreferrer". The server already scheme-checks them, but a
link is rendered from whatever the client was handed — an old row, a different
server version, a tampered response — and "the backend validated it" is not a
reason to hand javascript: to an anchor tag.

Retiring a lot says plainly that the plantings survive it, since "will this wipe
my garden" is the reasonable fear.

Closes #51

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 02:02:37 -04:00
steve 7f8b5254d0 Journal UI: write and read season notes where you're already looking (#53) (#67)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:55:45 +00:00
25 changed files with 1704 additions and 15 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`. - **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.
- **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`. - **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, and later the journal and chat panel 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. - **One rail, tabs inside it.** The inspector, history, journal, and later the chat panel 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.
## Roadmap ## Roadmap
+1
View File
@@ -90,6 +90,7 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// plop, so it inherits the ordinary garden-role check. // plop, so it inherits the ordinary garden-role check.
gardens.GET("/:id/journal", h.listJournal) gardens.GET("/:id/journal", h.listJournal)
gardens.POST("/:id/journal", h.createJournalEntry) gardens.POST("/:id/journal", h.createJournalEntry)
gardens.GET("/:id/journal/counts", h.getJournalCounts)
// Sharing (owner-managed; a recipient may remove their own share). // Sharing (owner-managed; a recipient may remove their own share).
gardens.GET("/:id/shares", h.listShares) gardens.GET("/:id/shares", h.listShares)
+21
View File
@@ -71,6 +71,27 @@ func (h *handlers) listJournal(c *gin.Context) {
c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore}) c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore})
} }
// getJournalCounts powers the "there are notes about this bed" indicator, which
// has to work while the journal panel is closed — exactly when nothing else has
// loaded the entries. Key "0" is the garden itself.
func (h *handlers) getJournalCounts(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
counts, err := h.svc.JournalCounts(c.Request.Context(), mustActor(c).ID, gardenID)
if err != nil {
writeServiceError(c, err)
return
}
// JSON object keys are strings; the client parses them back to ids.
out := make(map[string]int, len(counts))
for id, n := range counts {
out[strconv.FormatInt(id, 10)] = n
}
c.JSON(http.StatusOK, gin.H{"counts": out})
}
func (h *handlers) createJournalEntry(c *gin.Context) { func (h *handlers) createJournalEntry(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id") gardenID, ok := parseIDParam(c, "id")
if !ok { if !ok {
+43
View File
@@ -155,3 +155,46 @@ func TestJournalRequiresAccessAPI(t *testing.T) {
t.Errorf("stranger delete: status %d, want 404", w.Code) t.Errorf("stranger delete: status %d, want 404", w.Code)
} }
} }
// TestJournalCountsAPI — the indicator that makes the log discoverable has to
// work while the journal panel is closed, so it's its own endpoint.
func TestJournalCountsAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
}, cookie)
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
counts := func() map[string]any {
t.Helper()
w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("counts: status %d, body %s", w.Code, w.Body.String())
}
c, _ := decodeMap(t, w.Body.Bytes())["counts"].(map[string]any)
return c
}
if len(counts()) != 0 {
t.Errorf("a garden with no entries should report no counts")
}
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "garden note"}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "bed note"}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "another"}, cookie)
c := counts()
// Key "0" is the garden itself; anything else is an object id.
if c["0"] != float64(1) {
t.Errorf("garden-level count = %v, want 1", c["0"])
}
if c[strconv.FormatInt(objectID, 10)] != float64(2) {
t.Errorf("bed count = %v, want 2", c[strconv.FormatInt(objectID, 10)])
}
if w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous counts: status %d, want 401", w.Code)
}
}
+9
View File
@@ -84,6 +84,15 @@ func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q Jo
return entries, false, nil return entries, false, nil
} }
// JournalCounts returns how many entries a garden holds per object (key 0 for
// entries about the garden itself), for an actor who can at least view it.
func (s *Service) JournalCounts(ctx context.Context, actorID, gardenID int64) (map[int64]int, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
return nil, err
}
return s.store.JournalCounts(ctx, gardenID)
}
// CreateJournalEntry writes an entry against a garden the actor can edit. // CreateJournalEntry writes an entry against a garden the actor can edit.
// //
// An entry may target the garden, one of its beds, or one plop in it — and the // An entry may target the garden, one of its beds, or one plop in it — and the
+32
View File
@@ -123,6 +123,38 @@ func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFi
return entries, nil return entries, nil
} }
// JournalCounts returns how many entries a garden holds, keyed by the object
// they are about — key 0 for entries about the garden itself.
//
// A count per object rather than a flag, and one query rather than one per bed:
// the indicator has to be available when the journal panel is closed, which is
// exactly when nothing else has loaded the entries.
func (d *DB) JournalCounts(ctx context.Context, gardenID int64) (map[int64]int, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT COALESCE(object_id, 0), COUNT(*)
FROM journal_entries WHERE garden_id = ?
GROUP BY COALESCE(object_id, 0)`,
gardenID)
if err != nil {
return nil, fmt.Errorf("store: journal counts: %w", err)
}
defer rows.Close()
counts := map[int64]int{}
for rows.Next() {
var objectID int64
var n int
if err := rows.Scan(&objectID, &n); err != nil {
return nil, fmt.Errorf("store: scan journal count: %w", err)
}
counts[objectID] = n
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate journal counts: %w", err)
}
return counts, nil
}
// UpdateJournalEntry applies a version-guarded update of the mutable columns. // UpdateJournalEntry applies a version-guarded update of the mutable columns.
// Same contract as every other mutable resource. The target (garden/object/ // Same contract as every other mutable resource. The target (garden/object/
// planting) and the author are immutable: an entry is a record of an observation, // planting) and the author are immutable: an entry is a record of an observation,
@@ -0,0 +1,50 @@
import { useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { errorMessage } from '@/lib/api'
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
/**
* Retire a lot. Worth confirming because it's the one place cost and germination
* data lives — and worth saying plainly that the plantings survive it, since
* "will this wipe my garden" is the reasonable fear.
*/
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
const del = useDeleteSeedLot()
const [error, setError] = useState<string | null>(null)
return (
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
<div className="flex flex-col gap-3">
<p className="text-sm text-fg">
{formatQuantity(lot.quantity)} {lot.unit}
{lot.vendor ? ` from ${lot.vendor}` : ''}
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
</p>
<p className="text-sm text-muted">
Anything planted from it stays exactly where it is it just stops being attributed to this purchase.
</p>
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={del.isPending}
onClick={() =>
del.mutate(lot.id, {
onSuccess: onClose,
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
})
}
>
{del.isPending ? 'Retiring…' : 'Retire lot'}
</Button>
</div>
</div>
</Modal>
)
}
+51 -1
View File
@@ -1,6 +1,10 @@
import { useState } from 'react'
import { PlantIcon } from './PlantIcon' import { PlantIcon } from './PlantIcon'
import { LotStateChip, SeedLotList } from './SeedLotList'
import { SourceLink } from './SourceLink'
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions' import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants' import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
import { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
import { formatSpacing, type UnitPref } from '@/lib/units' import { formatSpacing, type UnitPref } from '@/lib/units'
/** /**
@@ -11,17 +15,30 @@ import { formatSpacing, type UnitPref } from '@/lib/units'
export function PlantCard({ export function PlantCard({
plant, plant,
unit, unit,
lots,
onEdit, onEdit,
onDelete, onDelete,
onDuplicate, onDuplicate,
onAddLot,
onEditLot,
onDeleteLot,
}: { }: {
plant: Plant plant: Plant
unit: UnitPref unit: UnitPref
/** This plant's purchases. A lot may reference a built-in, so even a built-in
* card can carry seed. */
lots: SeedLot[]
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onDuplicate: () => void onDuplicate: () => void
onAddLot: () => void
onEditLot: (lot: SeedLot) => void
onDeleteLot: (lot: SeedLot) => void
}) { }) {
const builtin = isBuiltin(plant) const builtin = isBuiltin(plant)
const [showLots, setShowLots] = useState(false)
const summary = summarizeLots(lots)
return ( return (
<div className="flex flex-col rounded-xl border border-border bg-surface"> <div className="flex flex-col rounded-xl border border-border bg-surface">
<div className="flex items-start gap-3 p-4"> <div className="flex items-start gap-3 p-4">
@@ -38,6 +55,12 @@ export function PlantCard({
<p className="mt-0.5 text-sm text-muted"> <p className="mt-0.5 text-sm text-muted">
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing {CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
</p> </p>
{(plant.vendor || plant.sourceUrl) && (
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
{plant.vendor && <span>{plant.vendor}</span>}
<SourceLink url={plant.sourceUrl} />
</p>
)}
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>} {plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
</div> </div>
<span <span
@@ -46,7 +69,34 @@ export function PlantCard({
title={plant.color} title={plant.color}
/> />
</div> </div>
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5"> {showLots && (
<div className="border-t border-border px-3 py-2">
<SeedLotList lots={lots} canEdit onAdd={onAddLot} onEdit={onEditLot} onDelete={onDeleteLot} />
</div>
)}
<div className="flex items-center justify-end gap-1 border-t border-border px-2 py-1.5">
{/* The seed count sits with the actions rather than in the body: it's
what you scan for down a list of twenty packets, so it wants a fixed
place on the card. */}
<button
type="button"
onClick={() => setShowLots((v) => !v)}
className={`${cardActionClass} mr-auto flex items-center gap-1.5`}
aria-expanded={showLots}
>
{lots.length === 0 ? (
<span className="text-muted">No seed</span>
) : (
<>
<span className="tabular-nums">
{formatQuantity(summary.remaining)}
{summary.unit ? ` ${summary.unit}` : ''} left
</span>
<LotStateChip state={summary.state} />
</>
)}
</button>
<button type="button" onClick={onDuplicate} className={cardActionClass}> <button type="button" onClick={onDuplicate} className={cardActionClass}>
Duplicate Duplicate
</button> </button>
@@ -16,6 +16,7 @@ import {
type PlantCategory, type PlantCategory,
type PlantInput, type PlantInput,
} from '@/lib/plants' } from '@/lib/plants'
import { safeExternalUrl } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units' import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
const DEFAULT_COLOR = '#4a7c3f' const DEFAULT_COLOR = '#4a7c3f'
@@ -61,6 +62,8 @@ export function PlantFormModal({
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR)) const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON) const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
const [vendor, setVendor] = useState(source?.vendor ?? '')
const [notes, setNotes] = useState(source?.notes ?? '') const [notes, setNotes] = useState(source?.notes ?? '')
const [version, setVersion] = useState(plant?.version ?? 0) const [version, setVersion] = useState(plant?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null) const [conflict, setConflict] = useState<string | null>(null)
@@ -96,6 +99,14 @@ export function PlantFormModal({
daysToMaturity = d daysToMaturity = d
} }
// The server refuses anything that isn't http(s) with a host, but say so here
// rather than letting a paste of "johnnyseeds.com" come back as a generic
// error with no hint about which field or why.
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
setFormError('The source link needs to be a full http:// or https:// address.')
return
}
const input: PlantInput = { const input: PlantInput = {
name: name.trim(), name: name.trim(),
category, category,
@@ -103,6 +114,8 @@ export function PlantFormModal({
color, color,
icon: icon.trim(), icon: icon.trim(),
daysToMaturity, daysToMaturity,
sourceUrl: sourceUrl.trim(),
vendor: vendor.trim(),
notes: notes.trim(), notes: notes.trim(),
} }
@@ -194,6 +207,27 @@ export function PlantFormModal({
onChange={(e) => setDays(e.target.value)} onChange={(e) => setDays(e.target.value)}
/> />
{/* Provenance for the variety itself. What you bought and what's left
of it is a seed lot, added from the plant card. */}
<div className="grid grid-cols-2 gap-3">
<TextField
label="Vendor"
name="vendor"
placeholder="Johnny's Selected Seeds"
value={vendor}
onChange={(e) => setVendor(e.target.value)}
/>
<TextField
label="Source link"
name="sourceUrl"
type="url"
inputMode="url"
placeholder="https://…"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
/>
</div>
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} /> <TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
{formError && <Alert>{formError}</Alert>} {formError && <Alert>{formError}</Alert>}
+161
View File
@@ -0,0 +1,161 @@
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/cn'
import { SourceLink } from './SourceLink'
import {
formatCost,
formatQuantity,
formatUnitCost,
lotState,
type LotState,
type SeedLot,
} from '@/lib/seedLots'
const STATE_LABEL: Record<LotState, string> = {
over: 'over-planted',
empty: 'empty',
low: 'low',
ok: 'in stock',
unknown: 'no count',
}
// Encoded in colour AND words, because this is the thing you skim down a list of
// twenty packets deciding what to order — a bare number doesn't survive that.
const STATE_CLASS: Record<LotState, string> = {
// "over" is a discrepancy to look at rather than a shortage to act on, so it
// reads as a distinct warning rather than sharing "low"'s styling.
over: 'bg-orange-500/25 text-orange-900 dark:text-orange-200',
empty: 'bg-red-500/15 text-red-800 dark:text-red-300',
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
ok: 'bg-accent/20 text-accent-strong',
unknown: 'bg-border/60 text-muted',
}
export function LotStateChip({ state, className }: { state: LotState; className?: string }) {
return (
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
STATE_CLASS[state],
className,
)}
>
{STATE_LABEL[state]}
</span>
)
}
/** A proportional bar for how much of a lot is left, so the state reads before
* any of the text does. Omitted when there's no quantity to be a fraction of. */
function RemainingBar({ lot }: { lot: SeedLot }) {
if (lot.quantity <= 0) return null
const pct = Math.max(0, Math.min(100, (lot.remaining / lot.quantity) * 100))
const state = lotState(lot)
return (
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-border/60">
<div
className={cn(
'h-full rounded-full',
state === 'ok' ? 'bg-accent' : state === 'low' ? 'bg-amber-500' : 'bg-red-500',
)}
style={{ width: `${pct}%` }}
/>
</div>
)
}
/**
* A plant's purchases: what came from where, and what's left of each.
*
* Two lots of the same variety are two separate rows with independent counts,
* which is the whole reason inventory lives on the purchase rather than on the
* plant (#50).
*/
export function SeedLotList({
lots,
canEdit,
onAdd,
onEdit,
onDelete,
}: {
lots: SeedLot[]
canEdit: boolean
onAdd: () => void
onEdit: (lot: SeedLot) => void
onDelete: (lot: SeedLot) => void
}) {
return (
<div className="flex flex-col gap-2">
{lots.length === 0 && (
<p className="text-xs text-muted">
No seed recorded. Add a lot to track what you bought and how much is left.
</p>
)}
{lots.map((lot) => (
<LotRow key={lot.id} lot={lot} canEdit={canEdit} onEdit={() => onEdit(lot)} onDelete={() => onDelete(lot)} />
))}
{canEdit && (
<Button variant="ghost" className="self-start px-2 py-1 text-xs" onClick={onAdd}>
+ Add seed lot
</Button>
)}
</div>
)
}
function LotRow({
lot,
canEdit,
onEdit,
onDelete,
}: {
lot: SeedLot
canEdit: boolean
onEdit: () => void
onDelete: () => void
}) {
const cost = formatCost(lot.costCents)
const unitCost = formatUnitCost(lot)
return (
<div className="rounded-lg border border-border px-2 py-1.5">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-sm text-fg">
<span className="font-medium tabular-nums">
{formatQuantity(lot.remaining)} / {formatQuantity(lot.quantity)} {lot.unit}
</span>
<LotStateChip state={lotState(lot)} />
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted">
{lot.vendor && <span>{lot.vendor}</span>}
{lot.packedForYear != null && <span>packed for {lot.packedForYear}</span>}
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
<SourceLink url={lot.sourceUrl} />
</p>
<RemainingBar lot={lot} />
</div>
{canEdit && (
<div className="flex shrink-0 flex-col items-end">
<button
type="button"
onClick={onEdit}
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Edit
</button>
<button
type="button"
onClick={onDelete}
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
>
Retire
</button>
</div>
)}
</div>
{lot.notes && <p className="mt-1 text-xs text-muted">{lot.notes}</p>}
</div>
)
}
+248
View File
@@ -0,0 +1,248 @@
import { useState, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { Select } from '@/components/ui/Select'
import { TextArea } from '@/components/ui/TextArea'
import { TextField } from '@/components/ui/TextField'
import { errorMessage } from '@/lib/api'
import {
conflictSeedLot,
LOT_UNITS,
safeExternalUrl,
useCreateSeedLot,
useUpdateSeedLot,
type LotUnit,
type SeedLot,
} from '@/lib/seedLots'
import type { Plant } from '@/lib/plants'
/**
* Record a purchase, or correct one. Everything except quantity and unit is
* optional — the point is to make writing it down cheap enough to bother with,
* and a form that demands a SKU and a lot code gets skipped.
*/
export function SeedLotModal({
plant,
lot,
onClose,
}: {
plant: Plant
/** Editing an existing lot, or undefined to record a new one. */
lot?: SeedLot
onClose: () => void
}) {
const isEdit = !!lot
const create = useCreateSeedLot()
const update = useUpdateSeedLot()
const pending = create.isPending || update.isPending
// A new lot inherits the plant's vendor and source link, since the usual case
// is buying the variety you already recorded from the place you recorded it.
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
const [sku, setSku] = useState(lot?.sku ?? '')
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
const [notes, setNotes] = useState(lot?.notes ?? '')
const [version, setVersion] = useState(lot?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setConflict(null)
const qty = quantity.trim() === '' ? 0 : Number(quantity)
if (!Number.isFinite(qty) || qty < 0) {
setError('Quantity must be a number, or left blank.')
return
}
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
setError('The source link needs to be a full http:// or https:// address.')
return
}
let year: number | null = null
if (packedForYear.trim()) {
const y = Number(packedForYear)
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
setError('Packed-for year should be a four-digit year.')
return
}
year = y
}
let costCents: number | null = null
if (cost.trim()) {
const c = Number(cost)
if (!Number.isFinite(c) || c < 0) {
setError('Cost must be an amount, or left blank.')
return
}
costCents = Math.round(c * 100)
}
let germinationPct: number | null = null
if (germination.trim()) {
const g = Number(germination)
if (!Number.isFinite(g) || g < 0 || g > 100) {
setError('Germination is a percentage between 0 and 100.')
return
}
germinationPct = g
}
const input = {
plantId: plant.id,
vendor: vendor.trim(),
sourceUrl: sourceUrl.trim(),
sku: sku.trim(),
lotCode: lotCode.trim(),
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
packedForYear: year,
quantity: qty,
unit,
costCents,
germinationPct,
notes: notes.trim(),
}
try {
if (isEdit) {
await update.mutateAsync({ id: lot.id, version, ...input })
} else {
await create.mutateAsync(input)
}
onClose()
} catch (err) {
const current = conflictSeedLot(err)
if (current) {
// Someone edited this lot elsewhere. Rebase onto the fresh row so a
// re-save applies, rather than making them retype everything — the same
// contract every other version-guarded form here honours.
setVersion(current.version)
setVendor(current.vendor)
setSourceUrl(current.sourceUrl)
setQuantity(String(current.quantity))
setUnit(current.unit)
setPurchasedAt(current.purchasedAt ?? '')
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
setSku(current.sku)
setLotCode(current.lotCode)
setNotes(current.notes)
setConflict('This lot changed elsewhere. The latest values are shown — review and save again.')
return
}
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
}
}
return (
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
{conflict && <Alert tone="info">{conflict}</Alert>}
<div className="grid grid-cols-2 gap-3">
<TextField
label="Quantity"
name="quantity"
type="number"
inputMode="decimal"
step="any"
min="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<Select
label="Unit"
name="unit"
value={unit}
onChange={(e) => setUnit(e.target.value as LotUnit)}
options={LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
<TextField
label="Source link"
name="sourceUrl"
type="url"
inputMode="url"
placeholder="https://…"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField
label="Purchased"
name="purchasedAt"
type="date"
value={purchasedAt}
onChange={(e) => setPurchasedAt(e.target.value)}
/>
<TextField
label="Packed for"
name="packedForYear"
type="number"
inputMode="numeric"
placeholder="2026"
value={packedForYear}
onChange={(e) => setPackedForYear(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField
label="Cost"
name="cost"
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="4.99"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
<TextField
label="Germination %"
name="germination"
type="number"
inputMode="decimal"
step="any"
min="0"
max="100"
value={germination}
onChange={(e) => setGermination(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
</div>
<TextArea label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record lot'}
</Button>
</div>
</form>
</Modal>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { safeExternalUrl } from '@/lib/seedLots'
/**
* A link out to where seed came from.
*
* The href is a URL somebody pasted, so it is re-checked here before rendering
* and carries rel="noopener noreferrer" — the server scheme-checks it too (#50),
* but a link is rendered from whatever the client was handed, and "the backend
* validated it" is not a reason to hand javascript: to an anchor tag.
*
* Renders nothing at all when the URL is absent or unsafe, so callers don't each
* have to remember to guard.
*/
export function SourceLink({ url, label = 'source' }: { url: string; label?: string }) {
const safe = safeExternalUrl(url)
if (!safe) return null
return (
<a
href={safe}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-dotted underline-offset-2 hover:text-fg"
>
{label}
</a>
)
}
+3 -3
View File
@@ -4,9 +4,9 @@ import { cn } from '@/lib/cn'
/** /**
* The editor's side rail, and the answer to "four things want one rail". * The editor's side rail, and the answer to "four things want one rail".
* *
* The inspector, history, and (later) the journal and chat panel all want the * The inspector, history, journal, and (later) the chat panel all want the same
* same strip of screen. Rather than each bolting on its own chrome, they are * strip of screen. Rather than each bolting on its own chrome, they are tabs in
* tabs in one rail — which keeps the canvas one width instead of a different * one rail — which keeps the canvas one width instead of a different
* width per panel, and means adding the journal or chat is adding a tab. * width per panel, and means adding the journal or chat is adding a tab.
* *
* Two constraints shaped it: * Two constraints shaped it:
+9 -1
View File
@@ -96,6 +96,7 @@ export function GardenCanvas({
const focusedObjectId = useEditorStore((s) => s.focusedObjectId) const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
const setFocusedObject = useEditorStore((s) => s.setFocusedObject) const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
const armedPlant = useEditorStore((s) => s.armedPlant) const armedPlant = useEditorStore((s) => s.armedPlant)
const armedLotId = useEditorStore((s) => s.armedLotId)
const liveObject = useEditorStore((s) => s.liveObject) const liveObject = useEditorStore((s) => s.liveObject)
const livePlanting = useEditorStore((s) => s.livePlanting) const livePlanting = useEditorStore((s) => s.livePlanting)
const { fitToRect } = useViewport(svgRef) const { fitToRect } = useViewport(svgRef)
@@ -200,7 +201,14 @@ export function GardenCanvas({
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15) const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
// Stay armed for repeat-placement; don't select (the placement sheet covers // Stay armed for repeat-placement; don't select (the placement sheet covers
// the object, so a selection would be hidden until placement ends anyway). // the object, so a selection would be hidden until placement ends anyway).
createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm }) createPlanting.mutate({
objectId: focusedObject.id,
plantId: armedPlant.id,
xCm: x,
yCm: y,
radiusCm,
seedLotId: armedLotId ?? undefined,
})
} }
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0 const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
+18
View File
@@ -31,12 +31,20 @@ export function Inspector({
gardenId, gardenId,
unit, unit,
onFocus, onFocus,
onAddNote,
noteCount = 0,
readOnly = false, readOnly = false,
}: { }: {
object: EditorObject object: EditorObject
gardenId: number gardenId: number
unit: UnitPref unit: UnitPref
onFocus?: () => void onFocus?: () => void
/** Opens the journal scoped to this object. Two taps from a selected bed to
* typing is the bar; anything more and the log stays empty. */
onAddNote?: () => void
/** How many journal entries are about this object, so the log is discoverable
* from the thing it's about rather than being a panel you have to remember. */
noteCount?: number
readOnly?: boolean readOnly?: boolean
}) { }) {
const update = useUpdateObject(gardenId) const update = useUpdateObject(gardenId)
@@ -134,6 +142,16 @@ export function Inspector({
</Button> </Button>
)} )}
{onAddNote && (
<Button variant="ghost" onClick={onAddNote} className="w-full text-sm">
{noteCount > 0
? `📝 ${noteCount} ${noteCount === 1 ? 'note' : 'notes'}`
: readOnly
? '📝 No notes'
: '📝 Add note'}
</Button>
)}
{/* A disabled fieldset makes every control below read-only for viewers in {/* A disabled fieldset makes every control below read-only for viewers in
one shot (no per-input disabled). */} one shot (no per-input disabled). */}
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0"> <fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
+309
View File
@@ -0,0 +1,309 @@
import { useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { TextArea } from '@/components/ui/TextArea'
import { TextField } from '@/components/ui/TextField'
import { errorMessage } from '@/lib/api'
import {
formatObservedAt,
today,
useCreateJournalEntry,
useDeleteJournalEntry,
useJournal,
useUpdateJournalEntry,
type JournalEntry,
} from '@/lib/journal'
import type { EditorObject } from './types'
import { objectDisplayName } from './kinds'
/**
* The garden's journal: write an entry, read the season back.
*
* Notes get written standing in the garden holding a phone, usually one-handed.
* If it takes more than a couple of taps from looking at a bed to typing a
* sentence, the log stays empty — so the composer is open by default rather than
* behind an "add" button, and selecting a bed pre-scopes it to that bed.
*/
export function JournalPanel({
gardenId,
canEdit,
currentUserId,
isOwner,
objects,
scopeObjectId,
onScopeChange,
}: {
gardenId: number
canEdit: boolean
currentUserId?: number
isOwner: boolean
objects: EditorObject[]
/** Which bed the panel is filtered to, if any. */
scopeObjectId: number | null
onScopeChange: (id: number | null) => void
}) {
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-fg">Journal</h2>
{objects.length > 0 && (
<select
value={scopeObjectId ?? ''}
onChange={(e) => {
const id = Number(e.target.value)
onScopeChange(e.target.value === '' || !Number.isFinite(id) ? null : id)
}}
className="max-w-[9rem] truncate rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<option value="">Whole garden</option>
{objects.map((o) => (
<option key={o.id} value={o.id}>
{objectDisplayName(o)}
</option>
))}
</select>
)}
</div>
{canEdit && (
<Composer
gardenId={gardenId}
objectId={scopeObjectId}
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null}
/>
)}
{journal.isPending && <p className="text-sm text-muted">Loading</p>}
{journal.isError && entries.length === 0 && (
<Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>
)}
{journal.isSuccess && entries.length === 0 && (
<p className="text-sm text-muted">
{scopedObject
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
</p>
)}
<ol className="flex flex-col gap-2">
{entries.map((e) => (
<Entry
key={e.id}
entry={e}
gardenId={gardenId}
objects={objects}
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
canRewrite={canEdit && e.authorId === currentUserId}
/>
))}
</ol>
{journal.hasNextPage && (
<Button
variant="ghost"
className="text-sm"
disabled={journal.isFetchingNextPage}
onClick={() => void journal.fetchNextPage()}
>
{journal.isFetchingNextPage ? 'Loading…' : 'Load older'}
</Button>
)}
{journal.isError && entries.length > 0 && (
<p className="text-xs text-red-700 dark:text-red-400">
{errorMessage(journal.error, "Couldn't load older entries.")}
</p>
)}
</div>
)
}
function Composer({
gardenId,
objectId,
scopeLabel,
}: {
gardenId: number
objectId: number | null
scopeLabel: string | null
}) {
const create = useCreateJournalEntry(gardenId)
const [body, setBody] = useState('')
// Editable so an observation can be backdated: you write up Saturday on Sunday.
const [observedAt, setObservedAt] = useState(today())
const [error, setError] = useState<string | null>(null)
const submit = () => {
const text = body.trim()
if (!text) return
setError(null)
create.mutate(
{ body: text, observedAt, objectId: objectId ?? undefined },
{
onSuccess: () => {
setBody('')
setObservedAt(today())
},
onError: (err) => setError(errorMessage(err, "Couldn't save that note.")),
},
)
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-2">
<TextArea
label={scopeLabel ? `Note about ${scopeLabel}` : 'Note'}
name="journalBody"
rows={3}
placeholder="What happened?"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
<TextField
label="Observed"
name="observedAt"
type="date"
value={observedAt}
onChange={(e) => setObservedAt(e.target.value)}
/>
</div>
<Button className="shrink-0" disabled={create.isPending || body.trim() === ''} onClick={submit}>
{create.isPending ? 'Saving…' : 'Save note'}
</Button>
</div>
{error && <p className="text-xs text-red-700 dark:text-red-400">{error}</p>}
</div>
)
}
function Entry({
entry,
gardenId,
objects,
canDelete,
canRewrite,
}: {
entry: JournalEntry
gardenId: number
objects: EditorObject[]
/** May remove it: the author, or the garden owner. */
canDelete: boolean
/** May rewrite the text: the author only — rewriting someone else's
* observation under their name is a different act from removing it. */
canRewrite: boolean
}) {
const update = useUpdateJournalEntry(gardenId)
const del = useDeleteJournalEntry(gardenId)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(entry.body)
const [error, setError] = useState<string | null>(null)
const about = objects.find((o) => o.id === entry.objectId) ?? null
// Re-sync the draft when the entry changes underneath — a refetch after
// someone else's edit, or this entry's own successful save. Skipped while
// editing so a background refetch can't clobber what's being typed; the
// version guard is what catches a genuine collision.
const [syncedBody, setSyncedBody] = useState(entry.body)
if (!editing && entry.body !== syncedBody) {
setSyncedBody(entry.body)
setDraft(entry.body)
}
const save = () => {
const text = draft.trim()
if (!text) {
// Silence here reads as a broken button; say why nothing happened.
setError('An entry needs some text. Delete it instead if that\'s what you meant.')
return
}
setError(null)
update.mutate(
{ id: entry.id, version: entry.version, body: text },
{
onSuccess: () => {
setEditing(false)
setError(null)
},
onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")),
},
)
}
return (
<li className="rounded-lg border border-border px-2.5 py-2 text-sm">
{editing ? (
<div className="flex flex-col gap-2">
<TextArea label="Note" name={`entry-${entry.id}`} rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} />
<div className="flex justify-end gap-1">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setDraft(entry.body)
setEditing(false)
setError(null)
}}
>
Cancel
</Button>
<Button className="px-2 py-1 text-xs" disabled={update.isPending} onClick={save}>
{update.isPending ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-fg">{entry.body}</p>
)}
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
<time dateTime={entry.observedAt}>{formatObservedAt(entry.observedAt)}</time>
<span aria-hidden>·</span>
<span>{entry.authorName}</span>
{about && (
<>
<span aria-hidden>·</span>
<span className="rounded bg-border/60 px-1 py-px">{objectDisplayName(about)}</span>
</>
)}
{entry.plantingId != null && <span className="rounded bg-border/60 px-1 py-px">planting</span>}
</p>
{(canRewrite || canDelete) && !editing && (
<div className="mt-1 flex justify-end gap-1">
{canRewrite && (
<button
type="button"
onClick={() => setEditing(true)}
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Edit
</button>
)}
{canDelete && (
<button
type="button"
disabled={del.isPending}
onClick={() => {
setError(null) // clear a previous failure so a retry isn't read as another
del.mutate(entry.id, {
onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")),
})
}}
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
>
Delete
</button>
)}
</div>
)}
{error && <p className="mt-1 text-xs text-red-700 dark:text-red-400">{error}</p>}
</li>
)
}
+80 -3
View File
@@ -4,6 +4,16 @@ import { fieldControlClass } from '@/components/ui/field'
import { CategoryChips } from '@/components/plants/CategoryChips' import { CategoryChips } from '@/components/plants/CategoryChips'
import { PlantIcon } from '@/components/plants/PlantIcon' import { PlantIcon } from '@/components/plants/PlantIcon'
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants' import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import {
attributableLots,
formatQuantity,
lotsByPlant,
lotState,
summarizeLots,
useSeedLots,
type SeedLot,
} from '@/lib/seedLots'
import { LotStateChip } from '@/components/plants/SeedLotList'
import { formatSpacing, type UnitPref } from '@/lib/units' import { formatSpacing, type UnitPref } from '@/lib/units'
const RECENT_KEY = 'pansy:recent-plants' const RECENT_KEY = 'pansy:recent-plants'
@@ -41,11 +51,20 @@ export function PlantPicker({
onClose, onClose,
unit = 'metric', unit = 'metric',
}: { }: {
onSelect: (plant: Plant) => void // The chosen plant, and the lot it should be attributed to when that isn't
// ambiguous. Undefined lot means "don't attribute" — which is the honest
// answer when there are no lots, and the deliberate one when the user skips.
onSelect: (plant: Plant, lot?: SeedLot) => void
onClose: () => void onClose: () => void
unit?: UnitPref unit?: UnitPref
}) { }) {
const plants = usePlants() const plants = usePlants()
const seedLots = useSeedLots()
const lotsFor = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
// Set when a plant with SEVERAL lots is picked: which packet did this come out
// of? One lot auto-attributes and zero lots stays silent, because forcing that
// question on every placement is how a nicety becomes an obstacle.
const [choosingLotFor, setChoosingLotFor] = useState<Plant | null>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [category, setCategory] = useState<CategoryFilter>('all') const [category, setCategory] = useState<CategoryFilter>('all')
const [recent, setRecent] = useState<number[]>(() => loadRecent()) const [recent, setRecent] = useState<number[]>(() => loadRecent())
@@ -74,8 +93,21 @@ export function PlantPicker({
}, [all, recent, query]) }, [all, recent, query])
function choose(p: Plant) { function choose(p: Plant) {
const lots = attributableLots(lotsFor.get(p.id) ?? [])
if (lots.length > 1) {
setChoosingLotFor(p)
return
}
setRecent(recordRecent(p.id)) setRecent(recordRecent(p.id))
onSelect(p) // A single lot attributes even when its count says empty. The count records
// what was written down, not a fact about the packet — dropping attribution
// there would make a wrong number harder to correct rather than easier.
onSelect(p, lots[0])
}
function chooseLot(p: Plant, lot?: SeedLot) {
setRecent(recordRecent(p.id))
onSelect(p, lot)
} }
// A plant option row. keyPrefix namespaces the key so a plant appearing in // A plant option row. keyPrefix namespaces the key so a plant appearing in
@@ -92,11 +124,22 @@ export function PlantPicker({
<span className="block truncate font-medium text-fg">{p.name}</span> <span className="block truncate font-medium text-fg">{p.name}</span>
<span className="block text-xs text-muted"> <span className="block text-xs text-muted">
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)} {CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
{remainingLabel(lotsFor.get(p.id) ?? [])}
</span> </span>
</span> </span>
</button> </button>
) )
// What's left, shown where you're deciding what to plant. Silent when there
// are no lots — most plants won't have any, and "0 left" on all of them would
// be noise that trains you to ignore the number. summarizeLots owns the
// unit-agreement rule; a second copy of it here would drift.
function remainingLabel(lots: SeedLot[]): string {
if (lots.length === 0) return ''
const { remaining, unit } = summarizeLots(lots)
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
}
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4" className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
@@ -132,7 +175,41 @@ export function PlantPicker({
<CategoryChips value={category} onChange={setCategory} size="sm" /> <CategoryChips value={category} onChange={setCategory} size="sm" />
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-2"> {choosingLotFor && (
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
Which lot of {choosingLotFor.name}?
</p>
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
<button
key={lot.id}
type="button"
onClick={() => chooseLot(choosingLotFor, lot)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-fg">
{lot.vendor || 'Unnamed lot'}
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
</span>
<span className="block text-xs text-muted">
{formatQuantity(lot.remaining)} of {formatQuantity(lot.quantity)} {lot.unit} left
</span>
</span>
<LotStateChip state={lotState(lot)} />
</button>
))}
<button
type="button"
onClick={() => chooseLot(choosingLotFor, undefined)}
className="w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
>
Don't attribute to a lot
</button>
</div>
)}
<div className={cn('min-h-0 flex-1 overflow-y-auto p-2', choosingLotFor && 'hidden')}>
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>} {plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>} {plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
+17 -2
View File
@@ -38,10 +38,19 @@ interface EditorState {
seasonYear: number | null seasonYear: number | null
setSeasonYear: (year: number | null) => void setSeasonYear: (year: number | null) => void
// Which bed the journal tab is filtered to, or null for the whole garden.
// Separate from selectedId deliberately: you can scope the journal to a bed
// and then select something else without the list moving under you.
journalObjectId: number | null
setJournalObjectId: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays // The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing. // armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null armedPlant: Plant | null
setArmedPlant: (p: Plant | null) => void // Which seed lot placements should be attributed to, when the armed plant has
// one worth naming. Cleared with the plant.
armedLotId: number | null
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
// During a move/resize/rotate, the object's live geometry is held here so the // During a move/resize/rotate, the object's live geometry is held here so the
// canvas renders it instantly; the PATCH fires only on gesture end. // canvas renders it instantly; the PATCH fires only on gesture end.
@@ -88,8 +97,12 @@ export const useEditorStore = create<EditorState>((set) => ({
seasonYear: null, seasonYear: null,
setSeasonYear: (year) => set({ seasonYear: year }), setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null,
setJournalObjectId: (id) => set({ journalObjectId: id }),
armedPlant: null, armedPlant: null,
setArmedPlant: (p) => set({ armedPlant: p }), armedLotId: null,
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
liveObject: null, liveObject: null,
setLiveObject: (o) => set({ liveObject: o }), setLiveObject: (o) => set({ liveObject: o }),
@@ -109,10 +122,12 @@ export const useEditorStore = create<EditorState>((set) => ({
selectedPlantingId: null, selectedPlantingId: null,
focusedObjectId: null, focusedObjectId: null,
armedPlant: null, armedPlant: null,
armedLotId: null,
armedKind: null, armedKind: null,
liveObject: null, liveObject: null,
livePlanting: null, livePlanting: null,
railTab: null, railTab: null,
seasonYear: null, seasonYear: null,
journalObjectId: null,
}), }),
})) }))
+146
View File
@@ -0,0 +1,146 @@
// Grow journal data layer (#53): entries about a garden, a bed, or one plop.
//
// An entry is what HAPPENED and when, as opposed to the `notes` field on a
// garden/object/plant, which is what the thing IS. Entries accumulate; notes
// overwrite.
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
export const journalEntrySchema = z.object({
id: z.number(),
gardenId: z.number(),
objectId: z.number().optional(),
plantingId: z.number().optional(),
authorId: z.number(),
authorName: z.string().default(''),
body: z.string(),
// When it happened, which is not when it was written down.
observedAt: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type JournalEntry = z.infer<typeof journalEntrySchema>
const journalPageSchema = z.object({
entries: z.array(journalEntrySchema),
hasMore: z.boolean(),
})
/** Narrows the journal. Undefined fields don't filter. */
export interface JournalFilter {
objectId?: number
plantingId?: number
from?: string
to?: string
}
const PAGE_SIZE = 30
export function journalKey(gardenId: number, filter: JournalFilter = {}) {
return ['gardens', gardenId, 'journal', filter] as const
}
export function useJournal(gardenId: number, filter: JournalFilter = {}, enabled = true) {
return useInfiniteQuery({
queryKey: journalKey(gardenId, filter),
enabled,
initialPageParam: 0,
queryFn: async ({ pageParam }) =>
journalPageSchema.parse(
await api.get(`/gardens/${gardenId}/journal`, {
params: { ...filter, limit: PAGE_SIZE, offset: pageParam },
}),
),
getNextPageParam: (last, pages) => (last.hasMore ? pages.length * PAGE_SIZE : undefined),
})
}
const countsSchema = z.object({ counts: z.record(z.string(), z.number().int().nonnegative()) })
/**
* How many entries each object has, keyed by object id — 0 for the garden
* itself. Its own query rather than derived from the list, because the
* indicator has to work while the journal panel is closed, which is exactly when
* the list hasn't loaded.
*/
export function useJournalCounts(gardenId: number) {
return useQuery({
queryKey: ['gardens', gardenId, 'journal-counts'] as const,
queryFn: async (): Promise<Map<number, number>> => {
const { counts } = countsSchema.parse(await api.get(`/gardens/${gardenId}/journal/counts`))
return new Map(Object.entries(counts).map(([id, n]) => [Number(id), n]))
},
})
}
export interface JournalInput {
objectId?: number
plantingId?: number
body: string
observedAt?: string
}
// Every journal query for this garden, whatever its filter — a new entry may
// belong to several of them at once (the garden's, its bed's, a date range's),
// and working out which is more effort than refetching a short list.
function invalidateJournal(qc: ReturnType<typeof useQueryClient>, gardenId: number) {
void qc.invalidateQueries({ queryKey: ['gardens', gardenId, 'journal'] })
void qc.invalidateQueries({ queryKey: ['gardens', gardenId, 'journal-counts'] })
}
export function useCreateJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: JournalInput): Promise<JournalEntry> =>
journalEntrySchema.parse(await api.post(`/gardens/${gardenId}/journal`, input)),
onSuccess: () => invalidateJournal(qc, gardenId),
})
}
export function useUpdateJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (vars: { id: number; version: number; body?: string; observedAt?: string }): Promise<JournalEntry> => {
const { id, ...rest } = vars
return journalEntrySchema.parse(await api.patch(`/journal/${id}`, rest))
},
onSuccess: () => invalidateJournal(qc, gardenId),
onError: (err) => {
// A 409 means the row moved on. Refetch so the component receives the
// current version — otherwise a retry resends the stale one and conflicts
// forever, which reads as a button that simply doesn't work.
if (err instanceof ApiError && err.isConflict) invalidateJournal(qc, gardenId)
},
})
}
export function useDeleteJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: number): Promise<void> => {
await api.delete(`/journal/${id}`)
},
onSuccess: () => invalidateJournal(qc, gardenId),
})
}
/** Today as YYYY-MM-DD in the viewer's own timezone — "today" means the day you
* are standing in the garden, not the day it is in UTC. */
export function today(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** A date-only string as a short human label, without dragging the value
* through a Date (which would shift it by the timezone offset). */
export function formatObservedAt(iso: string): string {
const [y, m, d] = iso.split('-').map(Number)
// Out-of-range parts would silently roll over — 2026-13-45 becoming February
// 2027 — so show the raw string instead of a confidently wrong date.
if (!y || !m || !d || m < 1 || m > 12 || d < 1 || d > 31) return iso
return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
+2
View File
@@ -258,6 +258,8 @@ export interface PlantingCreate {
radiusCm: number radiusCm: number
count?: number | null count?: number | null
label?: string | null label?: string | null
/** Attributes the plop to a purchase, so that lot can report what's left. */
seedLotId?: number
} }
export function useCreatePlanting(gardenId: number) { export function useCreatePlanting(gardenId: number) {
+6
View File
@@ -31,6 +31,10 @@ export const plantSchema = z.object({
color: z.string(), color: z.string(),
icon: z.string(), icon: z.string(),
daysToMaturity: z.number().nullable().optional(), daysToMaturity: z.number().nullable().optional(),
// Provenance for the VARIETY — the page you'd go back to to buy it again.
// What you actually bought, and how much is left, lives in seedLots.ts.
sourceUrl: z.string().default(''),
vendor: z.string().default(''),
notes: z.string(), notes: z.string(),
version: z.number(), version: z.number(),
createdAt: z.string(), createdAt: z.string(),
@@ -75,6 +79,8 @@ export interface PlantInput {
color: string color: string
icon: string icon: string
daysToMaturity: number | null daysToMaturity: number | null
sourceUrl: string
vendor: string
notes: string notes: string
} }
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import {
attributableLots,
formatCost,
formatQuantity,
formatUnitCost,
lotState,
lotsByPlant,
safeExternalUrl,
summarizeLots,
type SeedLot,
} from './seedLots'
function lot(over: Partial<SeedLot> = {}): SeedLot {
return {
id: 1,
ownerId: 1,
plantId: 1,
vendor: '',
sourceUrl: '',
sku: '',
lotCode: '',
quantity: 100,
unit: 'seeds',
notes: '',
version: 1,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
used: 0,
remaining: 100,
...over,
}
}
describe('safeExternalUrl', () => {
// The server already scheme-checks this, but a link is rendered from whatever
// the client was handed. "The backend validated it" is not a reason to hand
// javascript: to an anchor tag.
it('accepts http and https', () => {
expect(safeExternalUrl('https://www.johnnyseeds.com/x')).toBe('https://www.johnnyseeds.com/x')
expect(safeExternalUrl('http://example.com/')).toBe('http://example.com/')
})
it('refuses everything else', () => {
for (const bad of [
'',
'javascript:alert(1)',
'JavaScript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'file:///etc/passwd',
'//evil.example.com',
'/seeds/garlic',
'not a url',
]) {
expect(safeExternalUrl(bad)).toBeNull()
}
})
})
describe('lotState', () => {
it('reads the state, not just the number', () => {
expect(lotState(lot({ quantity: 100, remaining: 100 }))).toBe('ok')
expect(lotState(lot({ quantity: 100, remaining: 20 }))).toBe('low')
expect(lotState(lot({ quantity: 100, remaining: 0 }))).toBe('empty')
// Planting more than you recorded buying is real, and worth showing plainly
// rather than clamping to zero.
expect(lotState(lot({ quantity: 100, remaining: -5 }))).toBe('over')
})
it('has nothing to say about a lot with no recorded quantity', () => {
expect(lotState(lot({ quantity: 0, remaining: 0 }))).toBe('unknown')
})
})
describe('summarizeLots', () => {
it('totals remaining and reports the worst state', () => {
const s = summarizeLots([
lot({ id: 1, quantity: 100, remaining: 80 }),
lot({ id: 2, quantity: 50, remaining: 5 }),
])
expect(s.remaining).toBe(85)
expect(s.unit).toBe('seeds')
expect(s.state).toBe('low') // the one that needs attention wins
})
it("drops the unit when lots don't agree, rather than summing dishonestly", () => {
const s = summarizeLots([
lot({ id: 1, unit: 'seeds', quantity: 100, remaining: 100 }),
lot({ id: 2, unit: 'grams', quantity: 10, remaining: 10 }),
])
expect(s.unit).toBeNull()
})
it('says nothing for a plant with no lots', () => {
expect(summarizeLots([])).toEqual({ remaining: 0, unit: null, state: 'unknown' })
})
})
describe('cost formatting', () => {
it('renders whole currency from cents', () => {
expect(formatCost(499)).toBe('$4.99')
expect(formatCost(undefined)).toBeNull()
})
it('gives per-unit cost extra precision when it would round to nothing', () => {
expect(formatUnitCost(lot({ costCents: 499, quantity: 100 }))).toBe('$0.050/seed')
expect(formatUnitCost(lot({ costCents: 1200, quantity: 4, unit: 'packets' }))).toBe('$3.00/packet')
})
it('has no per-unit cost without both halves', () => {
expect(formatUnitCost(lot({ costCents: undefined }))).toBeNull()
expect(formatUnitCost(lot({ costCents: 499, quantity: 0 }))).toBeNull()
})
})
describe('lotsByPlant', () => {
it('groups, and copes with nothing', () => {
const grouped = lotsByPlant([lot({ id: 1, plantId: 7 }), lot({ id: 2, plantId: 7 }), lot({ id: 3, plantId: 9 })])
expect(grouped.get(7)?.length).toBe(2)
expect(grouped.get(9)?.length).toBe(1)
expect(lotsByPlant(undefined).size).toBe(0)
})
})
describe('attributableLots', () => {
// An exhausted lot is still offered: the count records what was written down,
// not a fact about the packet, so refusing to attribute a planting because the
// number says zero would make a wrong number harder to correct, not easier.
it('offers exhausted lots, just not first', () => {
const empty = lot({ id: 1, remaining: 0 })
const full = lot({ id: 2, remaining: 100 })
expect(attributableLots([empty, full]).map((l) => l.id)).toEqual([2, 1])
expect(attributableLots([empty]).length).toBe(1)
})
it("doesn't mutate its input", () => {
const lots = [lot({ id: 1, remaining: 0 }), lot({ id: 2, remaining: 100 })]
attributableLots(lots)
expect(lots.map((l) => l.id)).toEqual([1, 2])
})
})
describe('formatQuantity', () => {
it('keeps whole counts whole and fractional ones readable', () => {
expect(formatQuantity(100)).toBe('100')
expect(formatQuantity(12.5)).toBe('12.5')
expect(formatQuantity(-15)).toBe('-15') // over-planted lots go negative
})
})
+212
View File
@@ -0,0 +1,212 @@
// Seed lots (#51): what you actually bought, and what's left of it.
//
// A lot is one purchase of one variety. Inventory belongs to the purchase rather
// than to the variety because the same garlic from two vendors in two years is
// two different things — see #50 for the reasoning behind the shape.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
export const lotUnitSchema = z.enum(['seeds', 'grams', 'ounces', 'packets', 'bulbs', 'plants'])
export type LotUnit = z.infer<typeof lotUnitSchema>
export const LOT_UNITS: { value: LotUnit; label: string }[] = [
{ value: 'seeds', label: 'seeds' },
{ value: 'grams', label: 'grams' },
{ value: 'ounces', label: 'ounces' },
{ value: 'packets', label: 'packets' },
{ value: 'bulbs', label: 'bulbs' },
{ value: 'plants', label: 'plants' },
]
export const seedLotSchema = z.object({
id: z.number(),
ownerId: z.number(),
plantId: z.number(),
vendor: z.string(),
sourceUrl: z.string(),
sku: z.string(),
lotCode: z.string(),
purchasedAt: z.string().optional(),
packedForYear: z.number().optional(),
quantity: z.number(),
unit: lotUnitSchema,
costCents: z.number().optional(),
germinationPct: z.number().optional(),
notes: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
// Computed server-side: used is the summed effective count of active
// plantings attributed to this lot; remaining is quantity - used, and may be
// NEGATIVE when more was planted than the lot recorded buying.
used: z.number(),
remaining: z.number(),
})
export type SeedLot = z.infer<typeof seedLotSchema>
const seedLotsKey = ['seed-lots'] as const
export function useSeedLots() {
return useQuery({
queryKey: seedLotsKey,
queryFn: async (): Promise<SeedLot[]> => z.array(seedLotSchema).parse(await api.get('/seed-lots')),
})
}
export interface SeedLotInput {
plantId: number
vendor: string
sourceUrl: string
sku: string
lotCode: string
purchasedAt: string | null
packedForYear: number | null
quantity: number
unit: LotUnit
costCents: number | null
germinationPct: number | null
notes: string
}
function invalidate(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: seedLotsKey })
}
export function useCreateSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: SeedLotInput): Promise<SeedLot> =>
seedLotSchema.parse(await api.post('/seed-lots', input)),
onSuccess: () => invalidate(qc),
})
}
export function useUpdateSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (vars: Partial<SeedLotInput> & { id: number; version: number }): Promise<SeedLot> => {
const { id, ...rest } = vars
return seedLotSchema.parse(await api.patch(`/seed-lots/${id}`, rest))
},
onSuccess: () => invalidate(qc),
})
}
export function useDeleteSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: number): Promise<void> => {
await api.delete(`/seed-lots/${id}`)
},
onSuccess: () => invalidate(qc),
})
}
/**
* The lots a placement could be attributed to, best first.
*
* Lots with seed left come first, but an exhausted one is still offered: the
* count is a record of what you wrote down, not a fact about the packet, and
* refusing to attribute a planting because the number says zero would make the
* number harder to correct rather than easier.
*/
export function attributableLots(lots: SeedLot[]): SeedLot[] {
return [...lots].sort((a, b) => Number(b.remaining > 0) - Number(a.remaining > 0))
}
/** The current server row carried by a 409, so an edit form can rebase onto it
* and the user can re-save rather than losing what they typed. Every other
* entity's form does this; a lot is no different. */
export function conflictSeedLot(err: unknown): SeedLot | null {
if (!(err instanceof ApiError) || !err.isConflict) return null
const parsed = seedLotSchema.safeParse((err.body as { current?: unknown })?.current)
return parsed.success ? parsed.data : null
}
/** Group lots by the plant they belong to. */
export function lotsByPlant(lots: SeedLot[] | undefined): Map<number, SeedLot[]> {
const map = new Map<number, SeedLot[]>()
for (const lot of lots ?? []) {
const list = map.get(lot.plantId)
if (list) list.push(lot)
else map.set(lot.plantId, [lot])
}
return map
}
/**
* How a lot is doing, as a state rather than a number — this is the thing you
* scan for when deciding what to order, and a number alone doesn't survive being
* skimmed down a list.
*
* "over" means more was planted than the lot recorded buying. That's a real
* situation (a miscounted packet, a lot entered after the fact) and worth
* showing plainly rather than clamping to zero and hiding the discrepancy.
*/
export type LotState = 'over' | 'empty' | 'low' | 'ok' | 'unknown'
const LOW_FRACTION = 0.2
export function lotState(lot: SeedLot): LotState {
if (lot.quantity <= 0) return 'unknown' // no quantity recorded: nothing to be low on
if (lot.remaining < 0) return 'over'
if (lot.remaining === 0) return 'empty'
if (lot.remaining / lot.quantity <= LOW_FRACTION) return 'low'
return 'ok'
}
/** The total remaining across a plant's lots, and the worst state among them —
* what a plant card shows before you open it. */
export function summarizeLots(lots: SeedLot[]): { remaining: number; unit: LotUnit | null; state: LotState } {
if (lots.length === 0) return { remaining: 0, unit: null, state: 'unknown' }
// Mixed units can't be summed honestly; report the majority unit's total only
// when they all agree, else leave the unit off and let the per-lot rows speak.
const unit = lots.every((l) => l.unit === lots[0].unit) ? lots[0].unit : null
const remaining = lots.reduce((sum, l) => sum + l.remaining, 0)
const order: LotState[] = ['over', 'empty', 'low', 'ok', 'unknown']
const state = order.find((s) => lots.some((l) => lotState(l) === s)) ?? 'unknown'
return { remaining, unit, state }
}
/** Quantities are stored as REAL because grams and ounces are fractional, but a
* whole number of seeds shouldn't render as "100.0". */
export function formatQuantity(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
/** Whole currency from cents, e.g. 499 → "$4.99". */
export function formatCost(cents: number | undefined): string | null {
if (cents == null) return null
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(cents / 100)
}
/** Cost per unit, when both are known and it says something useful. */
export function formatUnitCost(lot: SeedLot): string | null {
if (lot.costCents == null || lot.quantity <= 0) return null
const per = lot.costCents / 100 / lot.quantity
return `${new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: per < 0.1 ? 3 : 2 }).format(per)}/${singular(lot.unit)}`
}
function singular(unit: LotUnit): string {
return unit.endsWith('s') ? unit.slice(0, -1) : unit
}
/**
* Whether a stored URL is safe to render as a link.
*
* The server already scheme-checks this (#50), but a link is rendered from
* whatever the client was handed — an old row, a different server version, a
* response someone tampered with — and "the backend validated it" is not a
* reason to hand `javascript:` to an anchor tag. Cheap to check twice.
*/
export function safeExternalUrl(raw: string): string | null {
if (!raw) return null
try {
const u = new URL(raw)
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null
} catch {
return null
}
}
+60 -4
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas' import { GardenCanvas } from '@/editor/GardenCanvas'
import { EditorRail, type RailTab } from '@/editor/EditorRail' import { EditorRail, type RailTab } from '@/editor/EditorRail'
import { HistoryPanel } from '@/editor/HistoryPanel' import { HistoryPanel } from '@/editor/HistoryPanel'
import { JournalPanel } from '@/editor/JournalPanel'
import { Inspector } from '@/editor/Inspector' import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector' import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker' import { PlantPicker } from '@/editor/PlantPicker'
@@ -29,6 +30,8 @@ import {
} from '@/lib/objects' } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings' import { toEditorPlanting } from '@/lib/plantings'
import type { Plant } from '@/lib/plants' import type { Plant } from '@/lib/plants'
import { useJournalCounts } from '@/lib/journal'
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import { useSeedTray } from '@/lib/seedTray' import { useSeedTray } from '@/lib/seedTray'
import { usePageTitle } from '@/lib/usePageTitle' import { usePageTitle } from '@/lib/usePageTitle'
@@ -58,6 +61,13 @@ export function GardenEditorPage() {
const setFocusedObject = useEditorStore((s) => s.setFocusedObject) const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
const railTab = useEditorStore((s) => s.railTab) const railTab = useEditorStore((s) => s.railTab)
const setRailTab = useEditorStore((s) => s.setRailTab) const setRailTab = useEditorStore((s) => s.setRailTab)
const journalObjectId = useEditorStore((s) => s.journalObjectId)
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
const journalCounts = useJournalCounts(gid)
const journalTotal = useMemo(
() => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0),
[journalCounts.data],
)
const armedPlant = useEditorStore((s) => s.armedPlant) const armedPlant = useEditorStore((s) => s.armedPlant)
const setArmedPlant = useEditorStore((s) => s.setArmedPlant) const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
@@ -65,6 +75,8 @@ export function GardenEditorPage() {
const updateObject = useUpdateObject(gid) const updateObject = useUpdateObject(gid)
const ensurePlant = useEnsurePlantInFull(gid) const ensurePlant = useEnsurePlantInFull(gid)
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid) const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
const seedLots = useSeedLots()
const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
// Which plant-picker flow is open: 'place' arms a plant for repeat placement; // Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant. // 'change' swaps the selected plop's plant.
@@ -268,9 +280,18 @@ export function GardenEditorPage() {
// Arm a plant for tap-to-place. The full catalog carries plants not yet in this // Arm a plant for tap-to-place. The full catalog carries plants not yet in this
// garden's /full payload, so make sure the chosen one is in the cache first — // garden's /full payload, so make sure the chosen one is in the cache first —
// otherwise its placed plops render without an icon/color until a refetch. // otherwise its placed plops render without an icon/color until a refetch.
function armPlant(plant: Plant) { // The Seed Tray arms a plant directly, without going through the picker, so it
// has to do the picker's single-lot auto-attribution itself — otherwise the
// quickest way to place a plant is the one path that silently loses which
// packet it came from. Several lots is genuinely ambiguous, so that case stays
// unattributed rather than guessing; the picker is where you choose.
function armPlant(plant: Plant, lot?: SeedLot) {
ensurePlant(plant) ensurePlant(plant)
setArmedPlant(plant) if (lot === undefined) {
const lots = attributableLots(lotsByPlantId.get(plant.id) ?? [])
lot = lots.length === 1 ? lots[0] : undefined
}
setArmedPlant(plant, lot?.id ?? null)
} }
// Removing the armed plant from the tray also disarms it, so placement doesn't // Removing the armed plant from the tray also disarms it, so placement doesn't
@@ -285,14 +306,14 @@ export function GardenEditorPage() {
// a not-yet-placed plant isn't in that map, which used to silently abort the // a not-yet-placed plant isn't in that map, which used to silently abort the
// pick (nothing armed, picker left open). Picking (place OR change) makes sure // pick (nothing armed, picker left open). Picking (place OR change) makes sure
// the plant renders and drops it into this garden's tray for quick reuse. // the plant renders and drops it into this garden's tray for quick reuse.
function onPickPlant(plant: Plant) { function onPickPlant(plant: Plant, lot?: SeedLot) {
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
ensurePlant(plant) ensurePlant(plant)
addToTray(plant) addToTray(plant)
if (picker === 'change' && selectedPlop) { if (picker === 'change' && selectedPlop) {
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id }) updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
} else { } else {
setArmedPlant(plant) // 'place' mode: arm for repeat placement setArmedPlant(plant, lot?.id ?? null) // 'place' mode: arm for repeat placement
} }
setPicker(null) setPicker(null)
} }
@@ -316,6 +337,13 @@ export function GardenEditorPage() {
setFocusedObject(selectedObject.id) setFocusedObject(selectedObject.id)
select(null) select(null)
}} }}
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
onAddNote={() => {
// Two taps from a selected bed to typing: scope the journal to it
// and switch tabs. The composer is already open in there.
setJournalObjectId(selectedObject.id)
setRailTab('journal')
}}
/> />
) : selectedPlop ? ( ) : selectedPlop ? (
<PlopInspector <PlopInspector
@@ -332,6 +360,24 @@ export function GardenEditorPage() {
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p> <p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
), ),
}, },
{
id: 'journal',
label: 'Journal',
// The total on the tab is the discoverability half: a garden with a
// season's notes in it shouldn't look identical to an empty one.
badge: journalTotal,
render: () => (
<JournalPanel
gardenId={gid}
canEdit={canEdit}
currentUserId={me.data?.id}
isOwner={isOwner}
objects={objects}
scopeObjectId={journalObjectId}
onScopeChange={setJournalObjectId}
/>
),
},
{ {
id: 'history', id: 'history',
label: 'History', label: 'History',
@@ -362,6 +408,16 @@ export function GardenEditorPage() {
<Button <Button
variant="ghost" variant="ghost"
className="mt-2 w-full text-sm" className="mt-2 w-full text-sm"
onClick={() => {
setJournalObjectId(null)
setRailTab(railTab === 'journal' ? null : 'journal')
}}
>
Journal
</Button>
<Button
variant="ghost"
className="mt-1 w-full text-sm"
onClick={() => setRailTab(railTab === 'history' ? null : 'history')} onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
> >
History History
+15
View File
@@ -7,8 +7,11 @@ import { CategoryChips } from '@/components/plants/CategoryChips'
import { PlantCard } from '@/components/plants/PlantCard' import { PlantCard } from '@/components/plants/PlantCard'
import { PlantFormModal } from '@/components/plants/PlantFormModal' import { PlantFormModal } from '@/components/plants/PlantFormModal'
import { DeletePlantModal } from '@/components/plants/DeletePlantModal' import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
import { SeedLotModal } from '@/components/plants/SeedLotModal'
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
import { PlantPicker } from '@/editor/PlantPicker' import { PlantPicker } from '@/editor/PlantPicker'
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants' import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import type { UnitPref } from '@/lib/units' import type { UnitPref } from '@/lib/units'
import { usePageTitle } from '@/lib/usePageTitle' import { usePageTitle } from '@/lib/usePageTitle'
@@ -19,6 +22,9 @@ type Dialog =
| { kind: 'duplicate'; plant: Plant } | { kind: 'duplicate'; plant: Plant }
| { kind: 'delete'; plant: Plant } | { kind: 'delete'; plant: Plant }
| { kind: 'picker' } | { kind: 'picker' }
| { kind: 'addLot'; plant: Plant }
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
| { kind: 'deleteLot'; lot: SeedLot }
| null | null
// There's no per-user unit preference server-side (only per-garden), so the // There's no per-user unit preference server-side (only per-garden), so the
@@ -35,6 +41,8 @@ function loadUnit(): UnitPref {
export function PlantsPage() { export function PlantsPage() {
usePageTitle('Plants') usePageTitle('Plants')
const plants = usePlants() const plants = usePlants()
const seedLots = useSeedLots()
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
const [unit, setUnit] = useState<UnitPref>(() => loadUnit()) const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [category, setCategory] = useState<CategoryFilter>('all') const [category, setCategory] = useState<CategoryFilter>('all')
@@ -108,9 +116,13 @@ export function PlantsPage() {
key={p.id} key={p.id}
plant={p} plant={p}
unit={unit} unit={unit}
lots={lots.get(p.id) ?? []}
onEdit={() => setDialog({ kind: 'edit', plant: p })} onEdit={() => setDialog({ kind: 'edit', plant: p })}
onDelete={() => setDialog({ kind: 'delete', plant: p })} onDelete={() => setDialog({ kind: 'delete', plant: p })}
onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })} onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })}
onAddLot={() => setDialog({ kind: 'addLot', plant: p })}
onEditLot={(lot) => setDialog({ kind: 'editLot', plant: p, lot })}
onDeleteLot={(lot) => setDialog({ kind: 'deleteLot', lot })}
/> />
))} ))}
</div> </div>
@@ -121,6 +133,9 @@ export function PlantsPage() {
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />} {dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
{dialog?.kind === 'duplicate' && <PlantFormModal template={dialog.plant} unit={unit} onClose={close} />} {dialog?.kind === 'duplicate' && <PlantFormModal template={dialog.plant} unit={unit} onClose={close} />}
{dialog?.kind === 'delete' && <DeletePlantModal plant={dialog.plant} onClose={close} />} {dialog?.kind === 'delete' && <DeletePlantModal plant={dialog.plant} onClose={close} />}
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
{dialog?.kind === 'picker' && ( {dialog?.kind === 'picker' && (
<PlantPicker <PlantPicker
unit={unit} unit={unit}