From 2923bf9f27c1c7904b2a4369d6689c0b5caf0ec1 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 12 Mar 2026 18:02:02 +0100 Subject: [PATCH] feat: add toggle to switch snapshot order --- .../backups/components/snapshot-timeline.tsx | 74 +++++++++++++++++-- .../modules/backups/routes/backup-details.tsx | 6 +- .../(dashboard)/backups/$backupId/index.tsx | 21 +++++- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/app/client/modules/backups/components/snapshot-timeline.tsx b/app/client/modules/backups/components/snapshot-timeline.tsx index cb4b5286..40489e56 100644 --- a/app/client/modules/backups/components/snapshot-timeline.tsx +++ b/app/client/modules/backups/components/snapshot-timeline.tsx @@ -1,22 +1,63 @@ -import { useRef } from "react"; +import { ArrowRightLeft } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; import type { ListSnapshotsResponse } from "~/client/api-client"; import { ByteSize } from "~/client/components/bytes-size"; import { Card, CardContent } from "~/client/components/ui/card"; +import { Button } from "~/client/components/ui/button"; import { formatDateWithMonth, formatShortDate, formatTime } from "~/client/lib/datetime"; import { cn } from "~/client/lib/utils"; import { RetentionCategoryBadges } from "~/client/components/retention-category-badges"; +export type SnapshotTimelineSortOrder = "asc" | "desc"; + +export const SNAPSHOT_TIMELINE_SORT_ORDER_COOKIE_NAME = "snapshot_timeline_sort_order"; +const SNAPSHOT_TIMELINE_SORT_ORDER_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +const getSortedSnapshots = (snapshots: ListSnapshotsResponse, sortOrder: SnapshotTimelineSortOrder) => { + return [...snapshots].sort((snapshotA, snapshotB) => { + return sortOrder === "desc" ? snapshotB.time - snapshotA.time : snapshotA.time - snapshotB.time; + }); +}; + +const getSnapshotRange = (snapshots: ListSnapshotsResponse) => { + if (snapshots.length === 0) { + return null; + } + + return snapshots.reduce( + (range, snapshot) => ({ + oldest: snapshot.time < range.oldest.time ? snapshot : range.oldest, + newest: snapshot.time > range.newest.time ? snapshot : range.newest, + }), + { oldest: snapshots[0], newest: snapshots[0] }, + ); +}; + interface Props { snapshots: ListSnapshotsResponse; snapshotId?: string; loading?: boolean; error?: string; + initialSortOrder?: SnapshotTimelineSortOrder; onSnapshotSelect: (snapshotId: string) => void; } export const SnapshotTimeline = (props: Props) => { - const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props; + const { snapshots, snapshotId, loading, onSnapshotSelect, error, initialSortOrder = "asc" } = props; const selectedRef = useRef(null); + const [sortOrder, setSortOrder] = useState(initialSortOrder); + const sortedSnapshots = useMemo(() => getSortedSnapshots(snapshots, sortOrder), [snapshots, sortOrder]); + const snapshotRange = useMemo(() => getSnapshotRange(snapshots), [snapshots]); + + const sortOrderButtonLabel = "Toggle snapshot sort order"; + + const handleToggleSortOrder = () => { + setSortOrder((currentSortOrder) => { + const nextSortOrder = currentSortOrder === "asc" ? "desc" : "asc"; + document.cookie = `${SNAPSHOT_TIMELINE_SORT_ORDER_COOKIE_NAME}=${nextSortOrder}; path=/; max-age=${SNAPSHOT_TIMELINE_SORT_ORDER_COOKIE_MAX_AGE}`; + return nextSortOrder; + }); + }; if (error) { return ( @@ -51,10 +92,26 @@ export const SnapshotTimeline = (props: Props) => { return (
+
+ Snapshots +
+ +
+
-
+
- {snapshots.map((snapshot) => { + {sortedSnapshots.map((snapshot) => { const date = new Date(snapshot.time); const isSelected = snapshotId === snapshot.short_id; @@ -88,9 +145,12 @@ export const SnapshotTimeline = (props: Props) => {
{snapshots.length} snapshots - - {formatDateWithMonth(snapshots[0].time)} - {formatDateWithMonth(snapshots.at(-1)?.time)} - + {snapshotRange && ( + + {formatDateWithMonth(snapshotRange.oldest.time)} -  + {formatDateWithMonth(snapshotRange.newest.time)} + + )}
diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 3af1d586..16ab75b9 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -43,6 +43,7 @@ import type { Snapshot, } from "~/client/lib/types"; import { useNavigate } from "@tanstack/react-router"; +import type { SnapshotTimelineSortOrder } from "../components/snapshot-timeline"; type Props = { loaderData: { @@ -51,14 +52,16 @@ type Props = { repos: Repository[]; scheduleNotifs: ScheduleNotification[]; mirrors: ScheduleMirror[]; + snapshotTimelineSortOrder: SnapshotTimelineSortOrder; snapshots?: Snapshot[]; }; scheduleId: string; initialSnapshotId?: string; + initialSnapshotSortOrder: SnapshotTimelineSortOrder; }; export function ScheduleDetailsPage(props: Props) { - const { loaderData, scheduleId, initialSnapshotId } = props; + const { loaderData, scheduleId, initialSnapshotId, initialSnapshotSortOrder } = props; const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -287,6 +290,7 @@ export function ScheduleDetailsPage(props: Props) { snapshots={snapshots ?? []} snapshotId={selectedSnapshot?.short_id} error={failureReason?.message} + initialSortOrder={initialSnapshotSortOrder} onSnapshotSelect={handleSnapshotSelect} /> diff --git a/app/routes/(dashboard)/backups/$backupId/index.tsx b/app/routes/(dashboard)/backups/$backupId/index.tsx index 7fd35a54..08121e1f 100644 --- a/app/routes/(dashboard)/backups/$backupId/index.tsx +++ b/app/routes/(dashboard)/backups/$backupId/index.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { getCookie } from "@tanstack/react-start/server"; import { z } from "zod"; import { getBackupProgressOptions, @@ -9,9 +11,15 @@ import { listRepositoriesOptions, listSnapshotsOptions, } from "~/client/api-client/@tanstack/react-query.gen"; +import { SNAPSHOT_TIMELINE_SORT_ORDER_COOKIE_NAME } from "~/client/modules/backups/components/snapshot-timeline"; import { ScheduleDetailsPage } from "~/client/modules/backups/routes/backup-details"; import { prefetchOrSkip } from "~/utils/prefetch"; +const fetchSnapshotTimelineSortOrder = createServerFn({ method: "GET" }).handler(async () => { + const order = getCookie(SNAPSHOT_TIMELINE_SORT_ORDER_COOKIE_NAME); + return order === "desc" ? "desc" : "asc"; +}); + export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({ component: RouteComponent, errorComponent: () =>
Failed to load backup
, @@ -19,13 +27,14 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({ loader: async ({ params, context }) => { const { backupId } = params; - const [schedule, notifs, repos, scheduleNotifs, mirrors] = await Promise.all([ + const [schedule, notifs, repos, scheduleNotifs, mirrors, _progress, snapshotTimelineSortOrder] = await Promise.all([ context.queryClient.ensureQueryData({ ...getBackupScheduleOptions({ path: { shortId: backupId } }) }), context.queryClient.ensureQueryData({ ...listNotificationDestinationsOptions() }), context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }), context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { shortId: backupId } }) }), context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { shortId: backupId } }) }), context.queryClient.ensureQueryData({ ...getBackupProgressOptions({ path: { shortId: backupId } }) }), + fetchSnapshotTimelineSortOrder(), ]); const snapshotOptions = listSnapshotsOptions({ @@ -41,6 +50,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({ repos, scheduleNotifs, mirrors, + snapshotTimelineSortOrder, snapshots: context.queryClient.getQueryData(snapshotOptions.queryKey), }; }, @@ -66,5 +76,12 @@ function RouteComponent() { const { backupId } = Route.useParams(); const search = Route.useSearch(); - return ; + return ( + + ); }