diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7515f20..39d87d0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -30,6 +30,11 @@ export class ApiError extends Error { get isUnauthorized(): boolean { 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 diff --git a/web/src/lib/lastGarden.test.ts b/web/src/lib/lastGarden.test.ts new file mode 100644 index 0000000..b014189 --- /dev/null +++ b/web/src/lib/lastGarden.test.ts @@ -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) { + const store = new Map() + 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 + }) +}) diff --git a/web/src/lib/lastGarden.ts b/web/src/lib/lastGarden.ts new file mode 100644 index 0000000..6d50609 --- /dev/null +++ b/web/src/lib/lastGarden.ts @@ -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 + } +} diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index 9116970..f0a6a85 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -36,6 +36,8 @@ import { useJournalCounts } from '@/lib/journal' import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots' import { useSeedTray } from '@/lib/seedTray' import { usePageTitle } from '@/lib/usePageTitle' +import { rememberLastGarden, forgetLastGarden } from '@/lib/lastGarden' +import { ApiError } from '@/lib/api' const routeApi = getRouteApi('/gardens/$gardenId') @@ -133,6 +135,24 @@ export function GardenEditorPage() { navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true }) }, [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 // focus mode so the canvas isn't stuck dimmed with no way out. useEffect(() => { @@ -256,12 +276,20 @@ export function GardenEditorPage() { }, []) if (full.isPending) return

Loading garden…

- 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 (
- Could not load this garden. + + {gone + ? 'That garden is no longer available — taking you to your gardens…' + : 'Could not load this garden.'} +
) + } const g = full.data.garden const garden: EditorGarden = { diff --git a/web/src/router.tsx b/web/src/router.tsx index 6c3c4c6..09429fc 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -18,6 +18,7 @@ import { SettingsPage } from '@/pages/SettingsPage' import { meQueryOptions } from '@/lib/auth' import { queryClient } from '@/lib/queryClient' import { safeRedirectPath } from '@/lib/redirect' +import { getLastGardenId } from '@/lib/lastGarden' interface RouterContext { queryClient: QueryClient @@ -66,7 +67,15 @@ async function requireAdmin(context: RouterContext, path: string) { const indexRoute = createRoute({ getParentRoute: () => rootRoute, 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: () => { + const last = getLastGardenId() + if (last != null) { + throw redirect({ to: '/gardens/$gardenId', params: { gardenId: String(last) } }) + } throw redirect({ to: '/gardens' }) }, })