Seed shelf UI: source links, lots, and what's left (#51) #68
@@ -3,8 +3,7 @@ import { Alert } from '@/components/ui/Alert'
|
|||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { errorMessage } from '@/lib/api'
|
import { errorMessage } from '@/lib/api'
|
||||||
import { useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||||
import { formatQuantity } from './SeedLotList'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retire a lot. Worth confirming because it's the one place cost and germination
|
* Retire a lot. Worth confirming because it's the one place cost and germination
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { PlantIcon } from './PlantIcon'
|
import { PlantIcon } from './PlantIcon'
|
||||||
import { LotStateChip, SeedLotList, formatQuantity } from './SeedLotList'
|
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 { safeExternalUrl, summarizeLots, type SeedLot } from '@/lib/seedLots'
|
import { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +38,7 @@ export function PlantCard({
|
|||||||
const builtin = isBuiltin(plant)
|
const builtin = isBuiltin(plant)
|
||||||
const [showLots, setShowLots] = useState(false)
|
const [showLots, setShowLots] = useState(false)
|
||||||
const summary = summarizeLots(lots)
|
const summary = summarizeLots(lots)
|
||||||
const sourceUrl = safeExternalUrl(plant.sourceUrl)
|
|
||||||
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">
|
||||||
@@ -54,21 +55,10 @@ 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 || sourceUrl) && (
|
{(plant.vendor || plant.sourceUrl) && (
|
||||||
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||||
{plant.vendor && <span>{plant.vendor}</span>}
|
{plant.vendor && <span>{plant.vendor}</span>}
|
||||||
{sourceUrl && (
|
<SourceLink url={plant.sourceUrl} />
|
||||||
<a
|
|
||||||
href={sourceUrl}
|
|
||||||
target="_blank"
|
|
||||||
// The href is a URL somebody pasted; noopener keeps it from
|
|
||||||
// getting a handle back to this window.
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
|
||||||
>
|
|
||||||
source ↗
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</p>
|
</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>}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
import { SourceLink } from './SourceLink'
|
||||||
import {
|
import {
|
||||||
formatCost,
|
formatCost,
|
||||||
|
formatQuantity,
|
||||||
formatUnitCost,
|
formatUnitCost,
|
||||||
lotState,
|
lotState,
|
||||||
safeExternalUrl,
|
|
||||||
type LotState,
|
type LotState,
|
||||||
type SeedLot,
|
type SeedLot,
|
||||||
} from '@/lib/seedLots'
|
} from '@/lib/seedLots'
|
||||||
@@ -20,7 +21,9 @@ const STATE_LABEL: Record<LotState, string> = {
|
|||||||
// Encoded in colour AND words, because this is the thing you skim down a list of
|
// 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.
|
// twenty packets deciding what to order — a bare number doesn't survive that.
|
||||||
const STATE_CLASS: Record<LotState, string> = {
|
const STATE_CLASS: Record<LotState, string> = {
|
||||||
over: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
// "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',
|
empty: 'bg-red-500/15 text-red-800 dark:text-red-300',
|
||||||
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
||||||
ok: 'bg-accent/20 text-accent-strong',
|
ok: 'bg-accent/20 text-accent-strong',
|
||||||
@@ -110,7 +113,6 @@ function LotRow({
|
|||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
const url = safeExternalUrl(lot.sourceUrl)
|
|
||||||
const cost = formatCost(lot.costCents)
|
const cost = formatCost(lot.costCents)
|
||||||
const unitCost = formatUnitCost(lot)
|
const unitCost = formatUnitCost(lot)
|
||||||
|
|
||||||
@@ -130,18 +132,7 @@ function LotRow({
|
|||||||
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
|
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
|
||||||
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
|
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
|
||||||
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
|
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
|
||||||
{url && (
|
<SourceLink url={lot.sourceUrl} />
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
// noopener/noreferrer on every outbound link: the target is a URL
|
|
||||||
// someone pasted, and window.opener would hand it a handle back.
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
|
||||||
>
|
|
||||||
source ↗
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<RemainingBar lot={lot} />
|
<RemainingBar lot={lot} />
|
||||||
</div>
|
</div>
|
||||||
@@ -168,9 +159,3 @@ function LotRow({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { TextArea } from '@/components/ui/TextArea'
|
|||||||
import { TextField } from '@/components/ui/TextField'
|
import { TextField } from '@/components/ui/TextField'
|
||||||
import { errorMessage } from '@/lib/api'
|
import { errorMessage } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
|
conflictSeedLot,
|
||||||
LOT_UNITS,
|
LOT_UNITS,
|
||||||
safeExternalUrl,
|
safeExternalUrl,
|
||||||
useCreateSeedLot,
|
useCreateSeedLot,
|
||||||
@@ -49,11 +50,14 @@ export function SeedLotModal({
|
|||||||
const [sku, setSku] = useState(lot?.sku ?? '')
|
const [sku, setSku] = useState(lot?.sku ?? '')
|
||||||
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
||||||
const [notes, setNotes] = useState(lot?.notes ?? '')
|
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)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setConflict(null)
|
||||||
|
|
||||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||||
if (!Number.isFinite(qty) || qty < 0) {
|
if (!Number.isFinite(qty) || qty < 0) {
|
||||||
@@ -109,12 +113,32 @@ export function SeedLotModal({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await update.mutateAsync({ id: lot.id, version: lot.version, ...input })
|
await update.mutateAsync({ id: lot.id, version, ...input })
|
||||||
} else {
|
} else {
|
||||||
await create.mutateAsync(input)
|
await create.mutateAsync(input)
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} 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.'))
|
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +146,8 @@ export function SeedLotModal({
|
|||||||
return (
|
return (
|
||||||
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
|
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||||
|
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<TextField
|
<TextField
|
||||||
label="Quantity"
|
label="Quantity"
|
||||||
|
|||||||
@@ -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,8 +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 { lotsByPlant, lotState, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
import {
|
||||||
import { LotStateChip, formatQuantity } from '@/components/plants/SeedLotList'
|
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'
|
||||||
@@ -85,12 +93,15 @@ export function PlantPicker({
|
|||||||
}, [all, recent, query])
|
}, [all, recent, query])
|
||||||
|
|
||||||
function choose(p: Plant) {
|
function choose(p: Plant) {
|
||||||
const lots = (lotsFor.get(p.id) ?? []).filter((l) => l.remaining > 0)
|
const lots = attributableLots(lotsFor.get(p.id) ?? [])
|
||||||
if (lots.length > 1) {
|
if (lots.length > 1) {
|
||||||
setChoosingLotFor(p)
|
setChoosingLotFor(p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setRecent(recordRecent(p.id))
|
setRecent(recordRecent(p.id))
|
||||||
|
// 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])
|
onSelect(p, lots[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +132,12 @@ export function PlantPicker({
|
|||||||
|
|
||||||
// What's left, shown where you're deciding what to plant. Silent when there
|
// 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
|
// 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.
|
// 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 {
|
function remainingLabel(lots: SeedLot[]): string {
|
||||||
if (lots.length === 0) return ''
|
if (lots.length === 0) return ''
|
||||||
const unitName = lots.every((l) => l.unit === lots[0].unit) ? ` ${lots[0].unit}` : ''
|
const { remaining, unit } = summarizeLots(lots)
|
||||||
const total = lots.reduce((sum, l) => sum + l.remaining, 0)
|
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
|
||||||
return ` · ${formatQuantity(total)}${unitName} left`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,9 +180,7 @@ export function PlantPicker({
|
|||||||
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||||
Which lot of {choosingLotFor.name}?
|
Which lot of {choosingLotFor.name}?
|
||||||
</p>
|
</p>
|
||||||
{(lotsFor.get(choosingLotFor.id) ?? [])
|
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
|
||||||
.filter((l) => l.remaining > 0)
|
|
||||||
.map((lot) => (
|
|
||||||
<button
|
<button
|
||||||
key={lot.id}
|
key={lot.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -189,7 +198,7 @@ export function PlantPicker({
|
|||||||
</span>
|
</span>
|
||||||
<LotStateChip state={lotState(lot)} />
|
<LotStateChip state={lotState(lot)} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => chooseLot(choosingLotFor, undefined)}
|
onClick={() => chooseLot(choosingLotFor, undefined)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
attributableLots,
|
||||||
formatCost,
|
formatCost,
|
||||||
|
formatQuantity,
|
||||||
formatUnitCost,
|
formatUnitCost,
|
||||||
lotState,
|
lotState,
|
||||||
lotsByPlant,
|
lotsByPlant,
|
||||||
@@ -119,3 +121,29 @@ describe('lotsByPlant', () => {
|
|||||||
expect(lotsByPlant(undefined).size).toBe(0)
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+28
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { api } from './api'
|
import { ApiError, api } from './api'
|
||||||
|
|
||||||
export const lotUnitSchema = z.enum(['seeds', 'grams', 'ounces', 'packets', 'bulbs', 'plants'])
|
export const lotUnitSchema = z.enum(['seeds', 'grams', 'ounces', 'packets', 'bulbs', 'plants'])
|
||||||
export type LotUnit = z.infer<typeof lotUnitSchema>
|
export type LotUnit = z.infer<typeof lotUnitSchema>
|
||||||
@@ -104,6 +104,27 @@ export function useDeleteSeedLot() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Group lots by the plant they belong to. */
|
||||||
export function lotsByPlant(lots: SeedLot[] | undefined): Map<number, SeedLot[]> {
|
export function lotsByPlant(lots: SeedLot[] | undefined): Map<number, SeedLot[]> {
|
||||||
const map = new Map<number, SeedLot[]>()
|
const map = new Map<number, SeedLot[]>()
|
||||||
@@ -149,6 +170,12 @@ export function summarizeLots(lots: SeedLot[]): { remaining: number; unit: LotUn
|
|||||||
return { remaining, unit, state }
|
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". */
|
/** Whole currency from cents, e.g. 499 → "$4.99". */
|
||||||
export function formatCost(cents: number | undefined): string | null {
|
export function formatCost(cents: number | undefined): string | null {
|
||||||
if (cents == null) return null
|
if (cents == null) return null
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
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 { useJournalCounts } from '@/lib/journal'
|
||||||
import type { SeedLot } from '@/lib/seedLots'
|
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'
|
||||||
|
|
||||||
@@ -75,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.
|
||||||
@@ -278,8 +280,17 @@ 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.
|
||||||
|
// 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) {
|
function armPlant(plant: Plant, lot?: SeedLot) {
|
||||||
ensurePlant(plant)
|
ensurePlant(plant)
|
||||||
|
if (lot === undefined) {
|
||||||
|
const lots = attributableLots(lotsByPlantId.get(plant.id) ?? [])
|
||||||
|
lot = lots.length === 1 ? lots[0] : undefined
|
||||||
|
}
|
||||||
setArmedPlant(plant, lot?.id ?? null)
|
setArmedPlant(plant, lot?.id ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user