Resume the last garden on this device (#97)
Build image / build-and-push (push) Successful in 17s
Gadfly review (reusable) / review (pull_request) Successful in 9m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m34s

`/` always dumped you on the gardens list; a phone user who lives in one
garden had to open the list and tap in every time. Now the device
remembers the garden it was last in and `/` resumes there.

- lib/lastGarden.ts: per-device localStorage (pansy:last-garden), same
  swallow-failures rationale as the seed tray / recents. getLastGardenId
  guards against a non-positive/garbage stored value.
- The `/` route redirects to the stored garden when present, else /gardens.
- The editor records the garden on successful load, and — if it 404s
  (deleted or access revoked) — forgets it (only if it's the stored one,
  so a bad direct link can't wipe a good resume target) and bounces to the
  list, so a stale id can't trap the user on an error screen. Transient
  errors still show the retryable message.
- ApiError.isNotFound getter (mirrors isConflict/isUnauthorized).

Verified live at 390px: resume into the last garden; a stale id bounces to
/gardens and clears itself. tsc + vitest (incl. new lastGarden test) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-22 00:44:58 -04:00
co-authored by Claude Opus 4.8
parent 283010dccb
commit 14af9502d4
5 changed files with 160 additions and 2 deletions
+5
View File
@@ -30,6 +30,11 @@ export class ApiError extends Error {
get isUnauthorized(): boolean { get isUnauthorized(): boolean {
return this.status === 401 return this.status === 401
} }
/** Resource is gone or masked (pansy returns 404 for no-access, not 403). */
get isNotFound(): boolean {
return this.status === 404
}
} }
type ParamValue = string | number | boolean | undefined | null type ParamValue = string | number | boolean | undefined | null
+70
View File
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getLastGardenId, rememberLastGarden, forgetLastGarden } from './lastGarden'
// The tests run in the node environment (no DOM), so stand up a minimal
// in-memory localStorage rather than pull in jsdom for one thin module.
function installStorage(impl?: Partial<Storage>) {
const store = new Map<string, string>()
const base: Storage = {
getItem: (k) => store.get(k) ?? null,
setItem: (k, v) => void store.set(k, String(v)),
removeItem: (k) => void store.delete(k),
clear: () => store.clear(),
key: (i) => [...store.keys()][i] ?? null,
get length() {
return store.size
},
}
;(globalThis as { localStorage?: Storage }).localStorage = { ...base, ...impl }
}
beforeEach(() => installStorage())
afterEach(() => {
delete (globalThis as { localStorage?: Storage }).localStorage
})
describe('lastGarden', () => {
it('round-trips a remembered garden id', () => {
expect(getLastGardenId()).toBeNull()
rememberLastGarden(42)
expect(getLastGardenId()).toBe(42)
})
it('rejects a non-positive or unparseable stored value', () => {
localStorage.setItem('pansy:last-garden', 'not-a-number')
expect(getLastGardenId()).toBeNull()
localStorage.setItem('pansy:last-garden', '0')
expect(getLastGardenId()).toBeNull()
localStorage.setItem('pansy:last-garden', '-3')
expect(getLastGardenId()).toBeNull()
})
it('forgets unconditionally with no argument', () => {
rememberLastGarden(7)
forgetLastGarden()
expect(getLastGardenId()).toBeNull()
})
it('forgets only when the stored id matches onlyIfEquals', () => {
rememberLastGarden(5)
// A 404 on a different (directly-linked) garden must not wipe the good resume.
forgetLastGarden(9)
expect(getLastGardenId()).toBe(5)
// A 404 on the stored garden itself does clear it.
forgetLastGarden(5)
expect(getLastGardenId()).toBeNull()
})
it('swallows storage failures instead of throwing', () => {
installStorage({
setItem: () => {
throw new Error('quota')
},
getItem: () => {
throw new Error('blocked')
},
})
expect(() => rememberLastGarden(1)).not.toThrow()
expect(getLastGardenId()).toBeNull() // getItem throwing → null, not a crash
})
})
+46
View File
@@ -0,0 +1,46 @@
// The last garden opened on THIS device, so a returning user resumes where they
// were instead of always landing on the gardens list. Per-device, in
// localStorage (same rationale as the seed tray and PlantPicker recents): a
// convenience, not authoritative state — a quota/availability failure is
// swallowed, and a stored id that no longer loads clears itself (see the editor).
//
// Only the id is stored. The garden is resolved by the editor on load; if it's
// gone (deleted, or access revoked), forgetLastGarden() drops it so `/` stops
// resuming a garden that can't open.
const KEY = 'pansy:last-garden'
/** The last-opened garden id on this device, or null if none/unparseable. */
export function getLastGardenId(): number | null {
try {
const raw = localStorage.getItem(KEY)
if (raw == null) return null
const n = Number(raw)
return Number.isInteger(n) && n > 0 ? n : null
} catch {
return null
}
}
/** Record the garden the device is now in, so `/` resumes here next time. */
export function rememberLastGarden(id: number): void {
try {
localStorage.setItem(KEY, String(id))
} catch {
// Resume is a convenience; ignore quota/availability failures.
}
}
/**
* Forget the stored last garden. With `onlyIfEquals`, clears only when the stored
* id matches — so a 404 on a directly-linked garden can't wipe a different, still
* good resume target the user had.
*/
export function forgetLastGarden(onlyIfEquals?: number): void {
try {
if (onlyIfEquals != null && getLastGardenId() !== onlyIfEquals) return
localStorage.removeItem(KEY)
} catch {
// ignore
}
}
+30 -2
View File
@@ -36,6 +36,8 @@ import { useJournalCounts } from '@/lib/journal'
import { attributableLots, lotsByPlant, useSeedLots, 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'
import { rememberLastGarden, forgetLastGarden } from '@/lib/lastGarden'
import { ApiError } from '@/lib/api'
const routeApi = getRouteApi('/gardens/$gardenId') const routeApi = getRouteApi('/gardens/$gardenId')
@@ -133,6 +135,24 @@ export function GardenEditorPage() {
navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true }) navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true })
}, [focusedObjectId, navigate]) }, [focusedObjectId, navigate])
// Remember this garden as the device's last-opened, so `/` resumes here next
// visit. Keyed on the live query: which garden EXISTS doesn't depend on the
// season being viewed.
useEffect(() => {
if (live.isSuccess) rememberLastGarden(gid)
}, [live.isSuccess, gid])
// A last garden that 404s (deleted, or access revoked) must not strand the user
// on an error screen the `/` resume keeps returning to: forget it (only if it's
// this one, so a bad direct link can't wipe a good resume target) and bounce to
// the list. Transient errors (500/network) fall through to the retryable screen.
useEffect(() => {
if (live.isError && live.error instanceof ApiError && live.error.isNotFound) {
forgetLastGarden(gid)
navigate({ to: '/gardens' })
}
}, [live.isError, live.error, gid, navigate])
// If the focused object vanished — deleted, or a stale ?focus id on load — leave // If the focused object vanished — deleted, or a stale ?focus id on load — leave
// focus mode so the canvas isn't stuck dimmed with no way out. // focus mode so the canvas isn't stuck dimmed with no way out.
useEffect(() => { useEffect(() => {
@@ -256,12 +276,20 @@ export function GardenEditorPage() {
}, []) }, [])
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden</p> if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden</p>
if (full.isError) if (full.isError) {
// A not-found is handled by the effect above (forget + bounce to the list);
// say so rather than flashing a dead-end error during the redirect.
const gone = full.error instanceof ApiError && full.error.isNotFound
return ( return (
<div className="p-6"> <div className="p-6">
<Alert>Could not load this garden.</Alert> <Alert>
{gone
? 'That garden is no longer available — taking you to your gardens…'
: 'Could not load this garden.'}
</Alert>
</div> </div>
) )
}
const g = full.data.garden const g = full.data.garden
const garden: EditorGarden = { const garden: EditorGarden = {
+9
View File
@@ -18,6 +18,7 @@ import { SettingsPage } from '@/pages/SettingsPage'
import { meQueryOptions } from '@/lib/auth' import { meQueryOptions } from '@/lib/auth'
import { queryClient } from '@/lib/queryClient' import { queryClient } from '@/lib/queryClient'
import { safeRedirectPath } from '@/lib/redirect' import { safeRedirectPath } from '@/lib/redirect'
import { getLastGardenId } from '@/lib/lastGarden'
interface RouterContext { interface RouterContext {
queryClient: QueryClient queryClient: QueryClient
@@ -66,7 +67,15 @@ async function requireAdmin(context: RouterContext, path: string) {
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
// Resume the garden this device was last in, so a returning user lands back in
// their plot rather than on the list every time. A stored id that no longer
// loads is cleared by the editor and bounces here to /gardens on the next
// visit, so this can't trap anyone on a dead garden.
beforeLoad: () => { beforeLoad: () => {
const last = getLastGardenId()
if (last != null) {
throw redirect({ to: '/gardens/$gardenId', params: { gardenId: String(last) } })
}
throw redirect({ to: '/gardens' }) throw redirect({ to: '/gardens' })
}, },
}) })