Files
pansy/web/src/editor/HistoryPanel.tsx
T
steve 1d2f0eba56
Build image / build-and-push (push) Successful in 10s
Let the container align the undo outcome note (#74)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 13:02:27 +00:00

147 lines
5.3 KiB
TypeScript

import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { describeCounts, totalChanges, useGardenHistory, useUndo, type ChangeSet } from '@/lib/history'
import { cn } from '@/lib/cn'
import { UndoButton } from './UndoButton'
/**
* The garden's change history, newest first, with an undo on each entry.
*
* A reverted entry stays in the list, marked — and the revert appears as its own
* entry, because that is what it is. Making the original disappear would be
* rewriting history rather than adding to it, and would leave no way to undo the
* undo.
*/
export function HistoryPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
const history = useGardenHistory(gardenId)
const undo = useUndo(gardenId)
const sets = history.data?.pages.flatMap((p) => p.changeSets) ?? []
return (
<div className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-fg">History</h2>
{history.isPending && <p className="text-sm text-muted">Loading</p>}
{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">
Nothing yet. Every change you make here shows up in this list, and can be undone from it.
</p>
)}
<ol className="flex flex-col gap-2">
{sets.map((cs) => (
<HistoryEntry key={cs.id} changeSet={cs} canEdit={canEdit} undo={undo} />
))}
</ol>
{history.hasNextPage && (
<>
<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
list entirely, because the delete cascades below the layer that records
changes. Better to state the gap than to imply cover we don't have. */}
<p className="border-t border-border pt-2 text-xs text-muted">
Deleting a whole garden isn't covered here and can't be undone.
</p>
</div>
)
}
function HistoryEntry({
changeSet,
canEdit,
undo,
}: {
changeSet: ChangeSet
canEdit: boolean
undo: ReturnType<typeof useUndo>
}) {
const counts = describeCounts(changeSet)
const reverted = changeSet.revertedById != null
const isRevert = changeSet.revertsId != null
return (
<li
className={cn(
'rounded-lg border border-border px-2.5 py-2 text-sm',
reverted && 'opacity-60',
)}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className={cn('text-fg', reverted && 'line-through decoration-muted')}>{changeSet.summary}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
{changeSet.source === 'agent' && (
<span className="rounded bg-accent/20 px-1 py-px font-medium uppercase tracking-wide text-accent-strong">
agent
</span>
)}
{isRevert && <span className="rounded bg-border/60 px-1 py-px font-medium">undo</span>}
<span>{changeSet.actorName}</span>
<span aria-hidden>·</span>
<time dateTime={changeSet.createdAt}>{relativeTime(changeSet.createdAt)}</time>
{counts && (
<>
<span aria-hidden>·</span>
<span>{counts}</span>
</>
)}
</p>
{reverted && <p className="mt-1 text-xs text-muted">Undone</p>}
</div>
{canEdit && totalChanges(changeSet) > 0 && (
<UndoButton
changeSet={changeSet}
undo={undo}
className="w-32 shrink-0 text-right"
label={reverted ? 'Undo again' : 'Undo'}
/>
)}
</div>
</li>
)
}
/** A compact "3m ago" / "yesterday" for a UTC timestamp. */
export function relativeTime(iso: string): string {
const then = Date.parse(iso)
if (Number.isNaN(then)) return iso
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'
// 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.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days === 1) return 'yesterday'
if (days < 30) return `${days}d ago`
return new Date(then).toLocaleDateString()
}