The best catch was one I'd have missed: the store already has resetTransient(), a single list of ephemeral editor state, and the new railTab didn't join it — so the public garden page cleared everything except the rail. Rather than adding railTab in two places, the editor page now calls resetTransient() instead of maintaining its own parallel list, which is what let them drift in the first place. The rail auto-switch keyed off a "something is selected" boolean, so it fired on the transition into having a selection and never again. Select a bed, switch to History, select a different bed — the boolean never changed, so the inspector never came forward, breaking the constraint the whole design was built around. Now keyed off the selected ids. Closing the rail cleared the canvas selection regardless of which tab you were on, so dismissing History deselected your bed. Only the inspector is about the selection, so only closing it deselects. describeUndo said "Undone." when the server reported a complete no-op (200 with a null change set, reachable by undoing a creation whose object is already gone). That reports work that didn't happen; it now says so. relativeTime rounded, so 18 hours ago read as "yesterday" and 90 minutes as "2h ago" — rounding up into the next unit reads as a bigger gap than actually elapsed. Floors throughout. Clock skew that puts a just-written entry slightly in the future now reads "just now" rather than falling through the negative. A failed "Load older" was swallowed entirely: the button simply stopped working. It now reports the error, while the top-level alert only takes over when there is nothing on screen at all. changeCountSchema took any number. Counts come from COUNT(*) and can only be non-negative integers, so constraining them makes bad data fail loudly instead of quietly hiding an Undo button (totalChanges gates it) or rendering "1.5 beds changed". Dropped useUndo's unused isPending — the per-change-set outcome already carries it, and per-row is right anyway. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -23,7 +23,9 @@ export function HistoryPanel({ gardenId, canEdit }: { gardenId: number; canEdit:
|
||||
<h2 className="text-sm font-semibold text-fg">History</h2>
|
||||
|
||||
{history.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{history.isError && <Alert>{errorMessage(history.error, 'Could not load history.')}</Alert>}
|
||||
{history.isError && sets.length === 0 && (
|
||||
<Alert>{errorMessage(history.error, 'Could not load history.')}</Alert>
|
||||
)}
|
||||
|
||||
{history.isSuccess && sets.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
@@ -38,14 +40,23 @@ export function HistoryPanel({ gardenId, canEdit }: { gardenId: number; canEdit:
|
||||
</ol>
|
||||
|
||||
{history.hasNextPage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{history.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{history.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
{/* isError stays true for a failed page even though earlier pages
|
||||
loaded, so say so rather than leaving a button that did nothing. */}
|
||||
{history.isError && sets.length > 0 && (
|
||||
<p className="text-xs text-red-700 dark:text-red-400">
|
||||
{errorMessage(history.error, "Couldn't load older entries.")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Said plainly rather than discovered: deleting a garden bypasses this
|
||||
@@ -118,13 +129,17 @@ function HistoryEntry({
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = Math.round((Date.now() - then) / 1000)
|
||||
const seconds = (Date.now() - then) / 1000
|
||||
// Clock skew between the server and this browser can put a just-written entry
|
||||
// slightly in the future; that's "just now", not a negative duration.
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.round(seconds / 60)
|
||||
// Floor throughout: 18 hours ago is "18h ago", not "yesterday". Rounding up
|
||||
// into the next unit reads as a bigger gap than actually elapsed.
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.round(hours / 24)
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(then).toLocaleDateString()
|
||||
|
||||
@@ -103,5 +103,6 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
armedKind: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
railTab: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -50,10 +50,22 @@ describe('describeUndo', () => {
|
||||
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
|
||||
|
||||
it('is a plain success when nothing conflicted', () => {
|
||||
const out = describeUndo(target, { changeSet: changeSet({ id: 2 }), conflicts: [] })
|
||||
const out = describeUndo(target, {
|
||||
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }),
|
||||
conflicts: [],
|
||||
})
|
||||
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
|
||||
})
|
||||
|
||||
// The server answers 200 with a null change set when every revision was
|
||||
// already a no-op — reachable by undoing a creation whose object is gone.
|
||||
// Claiming "Undone." there reports work that didn't happen.
|
||||
it("doesn't claim to have undone a no-op", () => {
|
||||
const out = describeUndo(target, { changeSet: null, conflicts: [] })
|
||||
expect(out.tone).toBe('ok')
|
||||
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
|
||||
})
|
||||
|
||||
// The case the issue was specific about: a partial revert really did change
|
||||
// things, so reporting a bare failure would be a lie about what applied.
|
||||
it('says how much applied and what was skipped', () => {
|
||||
|
||||
+11
-3
@@ -18,7 +18,10 @@ export type ChangeSource = z.infer<typeof changeSourceSchema>
|
||||
export const changeCountSchema = z.object({
|
||||
entityType: z.enum(['garden', 'object', 'planting']),
|
||||
op: z.enum(['create', 'update', 'delete']),
|
||||
n: z.number(),
|
||||
// COUNT(*) can only ever be a non-negative integer; constraining it means bad
|
||||
// data fails loudly rather than quietly hiding an Undo button (totalChanges
|
||||
// gates it) or rendering "1.5 beds changed".
|
||||
n: z.number().int().nonnegative(),
|
||||
})
|
||||
export type ChangeCount = z.infer<typeof changeCountSchema>
|
||||
|
||||
@@ -188,8 +191,9 @@ export function useUndo(gardenId: number) {
|
||||
|
||||
return {
|
||||
undo,
|
||||
// Per change set rather than a single global flag, so one row's pending
|
||||
// state can't disable the others.
|
||||
outcomeFor: (id: number): UndoOutcome | undefined => outcomes[id],
|
||||
isPending: revert.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +210,14 @@ export interface UndoOutcome {
|
||||
*/
|
||||
export function describeUndo(target: ChangeSet, result: RevertResult): UndoOutcome {
|
||||
const skipped = result.conflicts.map(describeConflict).join('; ')
|
||||
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
||||
if (result.conflicts.length === 0) {
|
||||
// The server answers 200 with a null change set when every revision was
|
||||
// already a no-op — reachable by undoing a creation whose object is gone.
|
||||
// Claiming "Undone." there would be reporting work that didn't happen.
|
||||
if (applied === 0) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
|
||||
return { tone: 'ok', message: 'Undone.' }
|
||||
}
|
||||
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
||||
if (applied === 0) {
|
||||
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
|
||||
}
|
||||
|
||||
@@ -44,9 +44,6 @@ export function GardenEditorPage() {
|
||||
const setRailTab = useEditorStore((s) => s.setRailTab)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
||||
const setArmedKind = useEditorStore((s) => s.setArmedKind)
|
||||
const setLiveObject = useEditorStore((s) => s.setLiveObject)
|
||||
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
|
||||
|
||||
const updatePlanting = useUpdatePlanting(gid)
|
||||
const updateObject = useUpdateObject(gid)
|
||||
@@ -83,15 +80,9 @@ export function GardenEditorPage() {
|
||||
// gardens (and on leaving). `focus` is intentionally read once here, not a dep,
|
||||
// so URL syncs below don't retrigger a full reset.
|
||||
useEffect(() => {
|
||||
const clear = () => {
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
setArmedKind(null)
|
||||
setArmedPlant(null)
|
||||
setLiveObject(null)
|
||||
setLivePlanting(null)
|
||||
setRailTab(null)
|
||||
}
|
||||
// resetTransient is the single list of ephemeral editor state, so new state
|
||||
// (the rail tab did exactly this) can't be forgotten here.
|
||||
const clear = () => useEditorStore.getState().resetTransient()
|
||||
clear()
|
||||
setFocusedObject(focus ?? null)
|
||||
return () => {
|
||||
@@ -116,14 +107,15 @@ export function GardenEditorPage() {
|
||||
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
||||
|
||||
// Selecting anything lands you in the inspector without a click — the rail
|
||||
// must never be something you operate before you can edit. Deselecting drops
|
||||
// back out of the inspector, but leaves History/Chat open if that's where you
|
||||
// were, since those aren't about the selection.
|
||||
const hasSelection = selectedId != null || selectedPlantingId != null
|
||||
// must never be something you operate before you can edit. Keyed off the
|
||||
// selected ids rather than a "something is selected" boolean, so selecting a
|
||||
// DIFFERENT object while History is open still brings the inspector forward.
|
||||
// Deselecting drops back out of the inspector but leaves History/Chat open if
|
||||
// that's where you were, since those aren't about the selection.
|
||||
useEffect(() => {
|
||||
if (hasSelection) setRailTab('inspector')
|
||||
if (selectedId != null || selectedPlantingId != null) setRailTab('inspector')
|
||||
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null)
|
||||
}, [hasSelection, setRailTab])
|
||||
}, [selectedId, selectedPlantingId, setRailTab])
|
||||
|
||||
const exitFocus = () => {
|
||||
setFocusedObject(null)
|
||||
@@ -405,9 +397,13 @@ export function GardenEditorPage() {
|
||||
activeId={railTab}
|
||||
onActivate={setRailTab}
|
||||
onClose={() => {
|
||||
// Only the inspector is *about* the selection, so only closing it
|
||||
// deselects; dismissing History leaves the canvas as you had it.
|
||||
if (railTab === 'inspector') {
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
}
|
||||
setRailTab(null)
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user