Season view: filter the editor to a year (#54) (#66)
Build image / build-and-push (push) Successful in 17s
Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #66.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* Which season the canvas is showing.
|
||||
*
|
||||
* "Now" is the live, editable garden — what is in the ground today. A year is a
|
||||
* read-only view of everything whose time in the ground overlapped that calendar
|
||||
* year, plops since removed included. They are genuinely different questions:
|
||||
* "what's growing" versus "what did I grow", and only the first one is editable.
|
||||
*
|
||||
* Only years the garden holds data for are offered. A free numeric field invites
|
||||
* a typo, and a typo'd year produces a confidently empty garden that reads as
|
||||
* data loss rather than a mistake.
|
||||
*/
|
||||
export function SeasonPicker({
|
||||
years,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
years: number[]
|
||||
value: number | null
|
||||
onChange: (year: number | null) => void
|
||||
}) {
|
||||
if (years.length === 0) return null
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span>Season</span>
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
className={cn(
|
||||
'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="">Now</option>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner that stops you thinking you're looking at now. Editing the past by
|
||||
* accident is the failure mode this whole feature introduces, so the state is
|
||||
* stated rather than implied by a dropdown you set a while ago — with the way
|
||||
* back to the live garden right next to it.
|
||||
*/
|
||||
export function SeasonBanner({ year, onExit }: { year: number; onExit: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-sm">
|
||||
<span className="font-medium text-amber-800 dark:text-amber-300">Viewing {year}</span>
|
||||
<span className="text-xs text-amber-800/80 dark:text-amber-300/80">
|
||||
read-only, including plantings since removed
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
className="ml-auto rounded px-1.5 py-0.5 text-xs font-medium text-amber-900 underline outline-none hover:no-underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-amber-200"
|
||||
>
|
||||
Back to now
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,12 @@ interface EditorState {
|
||||
railTab: string | null
|
||||
setRailTab: (tab: string | null) => void
|
||||
|
||||
// Which season the canvas is showing: null is "now" (live and editable), a
|
||||
// year is a read-only view of what was in the ground that year. Ephemeral —
|
||||
// which year you were last looking at is not a property of the garden.
|
||||
seasonYear: number | null
|
||||
setSeasonYear: (year: number | null) => void
|
||||
|
||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
@@ -79,6 +85,9 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
railTab: null,
|
||||
setRailTab: (tab) => set({ railTab: tab }),
|
||||
|
||||
seasonYear: null,
|
||||
setSeasonYear: (year) => set({ seasonYear: year }),
|
||||
|
||||
armedPlant: null,
|
||||
setArmedPlant: (p) => set({ armedPlant: p }),
|
||||
|
||||
@@ -104,5 +113,6 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
railTab: null,
|
||||
seasonYear: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -86,6 +86,38 @@ export function useGardenFull(gardenId: number) {
|
||||
return useQuery(gardenFullQueryOptions(gardenId))
|
||||
}
|
||||
|
||||
// A past season is a SEPARATE query under its own key, deliberately. The
|
||||
// optimistic mutations below all patch gardenFullKey(gardenId); if a year were
|
||||
// folded into that key they would write into whichever season happened to be on
|
||||
// screen. Season views are read-only, so they never need that machinery — and
|
||||
// keeping them out of it means they can't accidentally join it.
|
||||
export const gardenSeasonKey = (gardenId: number, year: number) =>
|
||||
['garden-season', gardenId, year] as const
|
||||
|
||||
/** A garden as it stood in a given calendar year: every plop whose time in the
|
||||
* ground overlapped it, including ones since removed. Read-only. */
|
||||
export function useGardenSeason(gardenId: number, year: number | null) {
|
||||
return useQuery({
|
||||
queryKey: gardenSeasonKey(gardenId, year ?? 0),
|
||||
enabled: year !== null,
|
||||
queryFn: async (): Promise<FullGarden> =>
|
||||
fullGardenSchema.parse(await api.get(`/gardens/${gardenId}/full`, { params: { year } })),
|
||||
})
|
||||
}
|
||||
|
||||
const yearsSchema = z.object({ years: z.array(z.number().int()) })
|
||||
|
||||
/** The years this garden holds planting data for, newest first, always
|
||||
* including the current one. Offering only years with data keeps the selector
|
||||
* from inviting a typo that produces a confidently empty garden. */
|
||||
export function useGardenYears(gardenId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['garden-years', gardenId] as const,
|
||||
queryFn: async (): Promise<number[]> =>
|
||||
yearsSchema.parse(await api.get(`/gardens/${gardenId}/years`)).years,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a plant is present in the /full cache's `plants` list. The full payload
|
||||
* carries only the garden's *referenced* plants (ListReferencedPlants), so a
|
||||
|
||||
@@ -12,12 +12,21 @@ import { Palette } from '@/editor/Palette'
|
||||
import { SeedTray } from '@/editor/SeedTray'
|
||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||
import { EditorHint } from '@/editor/EditorHint'
|
||||
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||||
import { objectDisplayName } from '@/editor/kinds'
|
||||
import { useEditorStore } from '@/editor/store'
|
||||
import type { EditorGarden } from '@/editor/types'
|
||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { toEditorObject, useEnsurePlantInFull, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||
import {
|
||||
toEditorObject,
|
||||
useEnsurePlantInFull,
|
||||
useGardenFull,
|
||||
useGardenSeason,
|
||||
useGardenYears,
|
||||
useUpdateObject,
|
||||
useUpdatePlanting,
|
||||
} from '@/lib/objects'
|
||||
import { toEditorPlanting } from '@/lib/plantings'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { useSeedTray } from '@/lib/seedTray'
|
||||
@@ -30,7 +39,14 @@ export function GardenEditorPage() {
|
||||
const gid = Number(gardenId)
|
||||
const { focus } = routeApi.useSearch()
|
||||
const navigate = routeApi.useNavigate()
|
||||
const full = useGardenFull(gid)
|
||||
const seasonYear = useEditorStore((s) => s.seasonYear)
|
||||
const setSeasonYear = useEditorStore((s) => s.setSeasonYear)
|
||||
// Two queries, one shown. The live one stays mounted so returning to "now" is
|
||||
// instant and so the optimistic mutation cache it owns is never displaced.
|
||||
const live = useGardenFull(gid)
|
||||
const season = useGardenSeason(gid, seasonYear)
|
||||
const full = seasonYear === null ? live : season
|
||||
const years = useGardenYears(gid)
|
||||
const me = useMe()
|
||||
usePageTitle(full.data?.garden.name ?? 'Garden')
|
||||
|
||||
@@ -68,7 +84,11 @@ export function GardenEditorPage() {
|
||||
// Role gating, computed before the effects/returns so the nudge handler can use
|
||||
// it. Ownership is the authoritative ownerId==me check.
|
||||
const gd = full.data?.garden
|
||||
const canEdit = gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor')
|
||||
// A past season is read-only: it is a record of what happened, and editing it
|
||||
// would be editing the past. This is the single gate — the palette, inspector,
|
||||
// nudging, placement and drag handles all key off canEdit.
|
||||
const canEdit =
|
||||
seasonYear === null && gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor')
|
||||
const isOwner = gd != null && me.data != null && gd.ownerId === me.data.id
|
||||
|
||||
// Latest values for the mount-once nudge keydown handler to read, so its effect
|
||||
@@ -330,9 +350,14 @@ export function GardenEditorPage() {
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
{!canEdit && (
|
||||
{!canEdit && seasonYear === null && (
|
||||
<p className="mb-2 rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 View only</p>
|
||||
)}
|
||||
{years.data && years.data.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
|
||||
</div>
|
||||
)}
|
||||
{focusedObjectId == null && canEdit && <Palette />}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -343,7 +368,8 @@ export function GardenEditorPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
||||
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
|
||||
{focusedObject && (
|
||||
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
|
||||
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
||||
@@ -380,7 +406,9 @@ export function GardenEditorPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
||||
</div>
|
||||
|
||||
{/* Empty-state hints (non-interactive overlays). */}
|
||||
{canEdit && focusedObjectId == null && objects.length === 0 && (
|
||||
|
||||
Reference in New Issue
Block a user