Compare commits
3
Commits
ffe32d58cf
...
4597978034
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4597978034 | ||
|
|
5189cb27a1 | ||
|
|
7f8b5254d0 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
{choosingLotFor && (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
<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
@@ -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,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user