Просмотр исходного кода

Show backups by Torqvoice as one list, with restore on each backup (#487)

* Show backups by Torqvoice as one list, with restore on each backup

The Backups by Torqvoice card on Settings, Data had two lists, restore
points and recent runs. They are now one list of backups, newest first and
grouped by day under a header that stays in view, each restore point with
its Explore and Restore buttons on its row. A backup that is queued,
running, failed or skipped, and a restore, sit in the list in their place.
The list scrolls in its own frame and shows 20 at a time, adding the next
20 as its end comes into view. The figures above it are tiles.

* Keep a backup the stored points miss, and date a run that never started by its finish

A backup whose restore-point refresh failed is newer than the newest stored point, and now shows as a run instead of disappearing until the next backup lists it. A queued run the server gave up on has only a finish time, and is sorted and shown by it instead of sitting under Today as the newest entry.
Bernt Christian Egeland 1 день назад
Родитель
Сommit
3f3b53d732

+ 23 - 26
messages/de/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Ihr Tarif enthält keine Sicherungen von Torqvoice mehr. Die Läufe wurden gestoppt; die Wiederherstellungspunkte werden bis {date} aufbewahrt und danach gelöscht.",
       "upsell": "Verfügbar mit Pro: stündliche Sicherungen durch Torqvoice mit Wiederherstellungspunkten, zu denen Sie zurückkehren können.",
       "planLink": "Tarife ansehen",
-      "pointsTitle": "Wiederherstellungspunkte",
       "pointsEmpty": "Die erste Sicherung ist noch nicht gelaufen.",
-      "showAll": "Alle {count} anzeigen",
-      "showFewer": "Weniger anzeigen",
       "needsOwner": "Nur der Inhaber der Werkstatt kann wiederherstellen.",
+      "listTitle": "Sicherungen",
+      "today": "Heute",
+      "yesterday": "Gestern",
+      "dayCount": "{count, plural, one {# Sicherung} other {# Sicherungen}}",
+      "latest": "Neueste",
+      "loadingMore": "Weitere Sicherungen werden geladen …",
+      "endOfList": "{count, plural, one {# Sicherung insgesamt} other {# Sicherungen insgesamt}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Sicherung in der Warteschlange",
+          "running": "Sicherung läuft",
+          "done": "Gesichert",
+          "failed": "Sicherung fehlgeschlagen",
+          "skipped": "Sicherung übersprungen"
+        },
+        "restore": {
+          "queued": "Wiederherstellung auf {time} in der Warteschlange",
+          "running": "Wird auf {time} wiederhergestellt",
+          "done": "Auf {time} wiederhergestellt",
+          "failed": "Wiederherstellung auf {time} fehlgeschlagen",
+          "skipped": "Wiederherstellung auf {time} übersprungen"
+        }
+      },
       "sizeNote": "Jeder Wiederherstellungspunkt ist für sich vollständig; seine Größe ist alles, was er umfasst. Das Repository speichert jedes Teil nur einmal, und spätere Läufe fügen nur hinzu, was sich geändert hat. Deshalb bleibt „Bei Torqvoice gespeichert“ weit unter der Summe der Größen.",
-      "recentTitle": "Letzte Läufe",
-      "noRuns": "Es wurden noch keine Sicherungen ausgeführt.",
-      "notStarted": "Nicht gestartet",
       "supportReason": "Vom Torqvoice-Support wiederhergestellt: {reason}",
-      "columns": {
-        "when": "Zeitpunkt",
-        "kind": "Art",
-        "size": "Größe",
-        "status": "Status",
-        "trigger": "Gestartet von",
-        "duration": "Dauer"
-      },
       "kind": {
         "scheduled": "Geplant",
         "manual": "Manuell",
         "safety": "Sicherheitskopie",
         "safetyHint": "Direkt vor einer Wiederherstellung erstellt, damit sie rückgängig gemacht werden kann"
       },
-      "trigger": {
-        "scheduled": "Geplant",
-        "manual": "Manuell",
-        "restore": "Wiederherstellung"
-      },
-      "status": {
-        "queued": "In der Warteschlange",
-        "running": "Läuft",
-        "done": "Fertig",
-        "failed": "Fehlgeschlagen",
-        "skipped": "Übersprungen"
-      },
       "explore": "Ansehen",
       "restore": {
         "action": "Wiederherstellen",

+ 23 - 26
messages/en/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Your plan no longer includes backups by Torqvoice. Runs have stopped; the restore points are kept until {date} and then deleted.",
       "upsell": "Available on Pro: hourly backups run by Torqvoice with restore points you can go back to.",
       "planLink": "See plans",
-      "pointsTitle": "Restore points",
       "pointsEmpty": "The first backup has not run yet.",
-      "showAll": "Show all {count}",
-      "showFewer": "Show fewer",
       "needsOwner": "Only the workshop owner can restore.",
+      "listTitle": "Backups",
+      "today": "Today",
+      "yesterday": "Yesterday",
+      "dayCount": "{count, plural, one {# backup} other {# backups}}",
+      "latest": "Latest",
+      "loadingMore": "Loading more backups…",
+      "endOfList": "{count, plural, one {# backup in total} other {# backups in total}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Backup queued",
+          "running": "Backing up",
+          "done": "Backed up",
+          "failed": "Backup failed",
+          "skipped": "Backup skipped"
+        },
+        "restore": {
+          "queued": "Restore to {time} queued",
+          "running": "Restoring to {time}",
+          "done": "Restored to {time}",
+          "failed": "Restore to {time} failed",
+          "skipped": "Restore to {time} skipped"
+        }
+      },
       "sizeNote": "Each restore point is complete on its own; its size is everything it covers. The repository stores every piece once and later runs add only what changed, so “Stored by Torqvoice” stays far below the sizes added together.",
-      "recentTitle": "Recent runs",
-      "noRuns": "No backups have run yet.",
-      "notStarted": "Not started",
       "supportReason": "Restored by Torqvoice support: {reason}",
-      "columns": {
-        "when": "When",
-        "kind": "Kind",
-        "size": "Size",
-        "status": "Status",
-        "trigger": "Started by",
-        "duration": "Duration"
-      },
       "kind": {
         "scheduled": "Scheduled",
         "manual": "Manual",
         "safety": "Safety copy",
         "safetyHint": "Taken right before a restore, so it can be undone"
       },
-      "trigger": {
-        "scheduled": "Scheduled",
-        "manual": "Manual",
-        "restore": "Restore"
-      },
-      "status": {
-        "queued": "Queued",
-        "running": "Running",
-        "done": "Done",
-        "failed": "Failed",
-        "skipped": "Skipped"
-      },
       "explore": "Explore",
       "restore": {
         "action": "Restore",

+ 23 - 26
messages/es/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Su plan ya no incluye las copias de seguridad de Torqvoice. Las ejecuciones se han detenido; los puntos de restauración se conservan hasta el {date} y después se eliminan.",
       "upsell": "Disponible en Pro: copias de seguridad cada hora hechas por Torqvoice, con puntos de restauración a los que puede volver.",
       "planLink": "Ver planes",
-      "pointsTitle": "Puntos de restauración",
       "pointsEmpty": "La primera copia de seguridad aún no se ha hecho.",
-      "showAll": "Mostrar los {count}",
-      "showFewer": "Mostrar menos",
       "needsOwner": "Solo el propietario del taller puede restaurar.",
+      "listTitle": "Copias de seguridad",
+      "today": "Hoy",
+      "yesterday": "Ayer",
+      "dayCount": "{count, plural, one {# copia} other {# copias}}",
+      "latest": "Más reciente",
+      "loadingMore": "Cargando más copias…",
+      "endOfList": "{count, plural, one {# copia en total} other {# copias en total}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Copia en cola",
+          "running": "Copia en curso",
+          "done": "Copia hecha",
+          "failed": "La copia falló",
+          "skipped": "Copia omitida"
+        },
+        "restore": {
+          "queued": "Restauración a {time} en cola",
+          "running": "Restaurando a {time}",
+          "done": "Restaurado a {time}",
+          "failed": "La restauración a {time} falló",
+          "skipped": "Restauración a {time} omitida"
+        }
+      },
       "sizeNote": "Cada punto de restauración está completo por sí solo; su tamaño es todo lo que abarca. El repositorio guarda cada pieza una sola vez y las ejecuciones posteriores añaden solo lo que cambió, por eso «Almacenado por Torqvoice» queda muy por debajo de la suma de los tamaños.",
-      "recentTitle": "Ejecuciones recientes",
-      "noRuns": "Todavía no se ha hecho ninguna copia de seguridad.",
-      "notStarted": "Sin iniciar",
       "supportReason": "Restaurado por el soporte de Torqvoice: {reason}",
-      "columns": {
-        "when": "Cuándo",
-        "kind": "Tipo",
-        "size": "Tamaño",
-        "status": "Estado",
-        "trigger": "Iniciada por",
-        "duration": "Duración"
-      },
       "kind": {
         "scheduled": "Programada",
         "manual": "Manual",
         "safety": "Copia de protección",
         "safetyHint": "Hecha justo antes de una restauración, para poder deshacerla"
       },
-      "trigger": {
-        "scheduled": "Programada",
-        "manual": "Manual",
-        "restore": "Restauración"
-      },
-      "status": {
-        "queued": "En cola",
-        "running": "En curso",
-        "done": "Completada",
-        "failed": "Fallida",
-        "skipped": "Omitida"
-      },
       "explore": "Explorar",
       "restore": {
         "action": "Restaurar",

+ 23 - 26
messages/fr/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Votre offre n'inclut plus les sauvegardes par Torqvoice. Les exécutions sont arrêtées ; les points de restauration sont conservés jusqu'au {date}, puis supprimés.",
       "upsell": "Disponible avec Pro : des sauvegardes horaires effectuées par Torqvoice, avec des points de restauration auxquels revenir.",
       "planLink": "Voir les offres",
-      "pointsTitle": "Points de restauration",
       "pointsEmpty": "La première sauvegarde n'a pas encore eu lieu.",
-      "showAll": "Afficher les {count}",
-      "showFewer": "Afficher moins",
       "needsOwner": "Seul le propriétaire de l'atelier peut restaurer.",
+      "listTitle": "Sauvegardes",
+      "today": "Aujourd'hui",
+      "yesterday": "Hier",
+      "dayCount": "{count, plural, one {# sauvegarde} other {# sauvegardes}}",
+      "latest": "La plus récente",
+      "loadingMore": "Chargement d'autres sauvegardes…",
+      "endOfList": "{count, plural, one {# sauvegarde au total} other {# sauvegardes au total}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Sauvegarde en file d'attente",
+          "running": "Sauvegarde en cours",
+          "done": "Sauvegardée",
+          "failed": "Échec de la sauvegarde",
+          "skipped": "Sauvegarde ignorée"
+        },
+        "restore": {
+          "queued": "Restauration à l'état du {time} en file d'attente",
+          "running": "Restauration à l'état du {time}",
+          "done": "Restauré à l'état du {time}",
+          "failed": "Échec de la restauration à l'état du {time}",
+          "skipped": "Restauration à l'état du {time} ignorée"
+        }
+      },
       "sizeNote": "Chaque point de restauration est complet à lui seul ; sa taille correspond à tout ce qu’il couvre. Le dépôt ne stocke chaque élément qu’une fois et les exécutions suivantes n’ajoutent que ce qui a changé, c’est pourquoi « Stocké par Torqvoice » reste bien en dessous de la somme des tailles.",
-      "recentTitle": "Exécutions récentes",
-      "noRuns": "Aucune sauvegarde n'a encore été effectuée.",
-      "notStarted": "Non démarrée",
       "supportReason": "Restauré par l'assistance Torqvoice : {reason}",
-      "columns": {
-        "when": "Date",
-        "kind": "Type",
-        "size": "Taille",
-        "status": "Statut",
-        "trigger": "Lancée par",
-        "duration": "Durée"
-      },
       "kind": {
         "scheduled": "Planifiée",
         "manual": "Manuelle",
         "safety": "Copie de sécurité",
         "safetyHint": "Réalisée juste avant une restauration, pour pouvoir l'annuler"
       },
-      "trigger": {
-        "scheduled": "Planifiée",
-        "manual": "Manuelle",
-        "restore": "Restauration"
-      },
-      "status": {
-        "queued": "En file d'attente",
-        "running": "En cours",
-        "done": "Terminée",
-        "failed": "Échouée",
-        "skipped": "Ignorée"
-      },
       "explore": "Explorer",
       "restore": {
         "action": "Restaurer",

+ 23 - 26
messages/it/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Il tuo piano non include più i backup di Torqvoice. Le esecuzioni si sono fermate; i punti di ripristino vengono conservati fino al {date} e poi eliminati.",
       "upsell": "Disponibile con Pro: backup orari eseguiti da Torqvoice, con punti di ripristino a cui puoi tornare.",
       "planLink": "Vedi i piani",
-      "pointsTitle": "Punti di ripristino",
       "pointsEmpty": "Il primo backup non è ancora stato eseguito.",
-      "showAll": "Mostra tutti i {count}",
-      "showFewer": "Mostra meno",
       "needsOwner": "Solo il proprietario dell'officina può eseguire il ripristino.",
+      "listTitle": "Backup",
+      "today": "Oggi",
+      "yesterday": "Ieri",
+      "dayCount": "{count, plural, one {# backup} other {# backup}}",
+      "latest": "Più recente",
+      "loadingMore": "Caricamento di altri backup…",
+      "endOfList": "{count, plural, one {# backup in totale} other {# backup in totale}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Backup in coda",
+          "running": "Backup in corso",
+          "done": "Backup completato",
+          "failed": "Backup non riuscito",
+          "skipped": "Backup saltato"
+        },
+        "restore": {
+          "queued": "Ripristino al {time} in coda",
+          "running": "Ripristino al {time} in corso",
+          "done": "Ripristinato al {time}",
+          "failed": "Ripristino al {time} non riuscito",
+          "skipped": "Ripristino al {time} saltato"
+        }
+      },
       "sizeNote": "Ogni punto di ripristino è completo di per sé; la sua dimensione è tutto ciò che copre. Il repository conserva ogni parte una sola volta e le esecuzioni successive aggiungono solo ciò che è cambiato, perciò «Archiviato da Torqvoice» resta molto al di sotto della somma delle dimensioni.",
-      "recentTitle": "Esecuzioni recenti",
-      "noRuns": "Non è ancora stato eseguito nessun backup.",
-      "notStarted": "Non avviato",
       "supportReason": "Ripristinato dall'assistenza Torqvoice: {reason}",
-      "columns": {
-        "when": "Quando",
-        "kind": "Tipo",
-        "size": "Dimensione",
-        "status": "Stato",
-        "trigger": "Avviato da",
-        "duration": "Durata"
-      },
       "kind": {
         "scheduled": "Pianificato",
         "manual": "Manuale",
         "safety": "Copia di sicurezza",
         "safetyHint": "Creata subito prima di un ripristino, per poterlo annullare"
       },
-      "trigger": {
-        "scheduled": "Pianificato",
-        "manual": "Manuale",
-        "restore": "Ripristino"
-      },
-      "status": {
-        "queued": "In coda",
-        "running": "In corso",
-        "done": "Completato",
-        "failed": "Non riuscito",
-        "skipped": "Saltato"
-      },
       "explore": "Esplora",
       "restore": {
         "action": "Ripristina",

+ 23 - 26
messages/lt/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Jūsų plane nebėra Torqvoice atsarginių kopijų. Vykdymai sustabdyti; atkūrimo taškai saugomi iki {date}, o tada ištrinami.",
       "upsell": "Prieinama su Pro planu: Torqvoice kas valandą kuriamos atsarginės kopijos su atkūrimo taškais, į kuriuos galite grįžti.",
       "planLink": "Peržiūrėti planus",
-      "pointsTitle": "Atkūrimo taškai",
       "pointsEmpty": "Pirmoji atsarginė kopija dar nesukurta.",
-      "showAll": "Rodyti visus ({count})",
-      "showFewer": "Rodyti mažiau",
       "needsOwner": "Atkurti gali tik dirbtuvės savininkas.",
+      "listTitle": "Atsarginės kopijos",
+      "today": "Šiandien",
+      "yesterday": "Vakar",
+      "dayCount": "{count, plural, one {# kopija} few {# kopijos} many {# kopijos} other {# kopijų}}",
+      "latest": "Naujausia",
+      "loadingMore": "Įkeliama daugiau kopijų…",
+      "endOfList": "{count, plural, one {Iš viso # kopija} few {Iš viso # kopijos} many {Iš viso # kopijos} other {Iš viso # kopijų}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Atsarginė kopija eilėje",
+          "running": "Kuriama atsarginė kopija",
+          "done": "Atsarginė kopija sukurta",
+          "failed": "Nepavyko sukurti atsarginės kopijos",
+          "skipped": "Atsarginė kopija praleista"
+        },
+        "restore": {
+          "queued": "Atkūrimas į būseną {time} eilėje",
+          "running": "Atkuriama į būseną {time}",
+          "done": "Atkurta į būseną {time}",
+          "failed": "Nepavyko atkurti į būseną {time}",
+          "skipped": "Atkūrimas į būseną {time} praleistas"
+        }
+      },
       "sizeNote": "Kiekvienas atkūrimo taškas yra pilnas pats savaime; jo dydis – viskas, ką jis apima. Saugykla kiekvieną dalį laiko tik kartą, o vėlesni paleidimai prideda tik tai, kas pasikeitė, todėl „Saugo Torqvoice“ lieka gerokai mažesnis už dydžių sumą.",
-      "recentTitle": "Paskutiniai vykdymai",
-      "noRuns": "Atsarginės kopijos dar nebuvo kuriamos.",
-      "notStarted": "Nepradėta",
       "supportReason": "Atkūrė Torqvoice pagalbos komanda: {reason}",
-      "columns": {
-        "when": "Kada",
-        "kind": "Tipas",
-        "size": "Dydis",
-        "status": "Būsena",
-        "trigger": "Kas pradėjo",
-        "duration": "Trukmė"
-      },
       "kind": {
         "scheduled": "Suplanuota",
         "manual": "Rankinė",
         "safety": "Apsauginė kopija",
         "safetyHint": "Sukurta prieš pat atkūrimą, kad jį būtų galima atšaukti"
       },
-      "trigger": {
-        "scheduled": "Pagal grafiką",
-        "manual": "Rankiniu būdu",
-        "restore": "Atkūrimas"
-      },
-      "status": {
-        "queued": "Eilėje",
-        "running": "Vykdoma",
-        "done": "Atlikta",
-        "failed": "Nepavyko",
-        "skipped": "Praleista"
-      },
       "explore": "Peržiūrėti",
       "restore": {
         "action": "Atkurti",

+ 23 - 26
messages/nb/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Planen din inkluderer ikke lenger sikkerhetskopier fra Torqvoice. Kjøringene er stoppet; gjenopprettingspunktene beholdes til {date} og slettes deretter.",
       "upsell": "Tilgjengelig med Pro: sikkerhetskopier hver time fra Torqvoice, med gjenopprettingspunkter du kan gå tilbake til.",
       "planLink": "Se planer",
-      "pointsTitle": "Gjenopprettingspunkter",
       "pointsEmpty": "Den første sikkerhetskopien har ikke kjørt ennå.",
-      "showAll": "Vis alle {count}",
-      "showFewer": "Vis færre",
       "needsOwner": "Bare verkstedets eier kan gjenopprette.",
+      "listTitle": "Sikkerhetskopier",
+      "today": "I dag",
+      "yesterday": "I går",
+      "dayCount": "{count, plural, one {# sikkerhetskopi} other {# sikkerhetskopier}}",
+      "latest": "Nyeste",
+      "loadingMore": "Henter flere sikkerhetskopier …",
+      "endOfList": "{count, plural, one {# sikkerhetskopi totalt} other {# sikkerhetskopier totalt}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Sikkerhetskopi i kø",
+          "running": "Tar sikkerhetskopi",
+          "done": "Sikkerhetskopi tatt",
+          "failed": "Sikkerhetskopien feilet",
+          "skipped": "Sikkerhetskopien ble hoppet over"
+        },
+        "restore": {
+          "queued": "Gjenoppretting til {time} i kø",
+          "running": "Gjenoppretter til {time}",
+          "done": "Gjenopprettet til {time}",
+          "failed": "Gjenoppretting til {time} feilet",
+          "skipped": "Gjenoppretting til {time} ble hoppet over"
+        }
+      },
       "sizeNote": "Hvert gjenopprettingspunkt er komplett i seg selv; størrelsen er alt det omfatter. Arkivet lagrer hver del bare én gang, og senere kjøringer legger bare til det som er endret. Derfor holder «Lagret hos Torqvoice» seg langt under summen av størrelsene.",
-      "recentTitle": "Siste kjøringer",
-      "noRuns": "Ingen sikkerhetskopier har kjørt ennå.",
-      "notStarted": "Ikke startet",
       "supportReason": "Gjenopprettet av Torqvoice-supporten: {reason}",
-      "columns": {
-        "when": "Når",
-        "kind": "Type",
-        "size": "Størrelse",
-        "status": "Status",
-        "trigger": "Startet av",
-        "duration": "Varighet"
-      },
       "kind": {
         "scheduled": "Planlagt",
         "manual": "Manuell",
         "safety": "Kopi før gjenoppretting",
         "safetyHint": "Tatt rett før en gjenoppretting, så den kan angres"
       },
-      "trigger": {
-        "scheduled": "Planlagt",
-        "manual": "Manuelt",
-        "restore": "Gjenoppretting"
-      },
-      "status": {
-        "queued": "I kø",
-        "running": "Kjører",
-        "done": "Ferdig",
-        "failed": "Feilet",
-        "skipped": "Hoppet over"
-      },
       "explore": "Utforsk",
       "restore": {
         "action": "Gjenopprett",

+ 23 - 26
messages/nl/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Uw abonnement bevat geen back-ups door Torqvoice meer. De runs zijn gestopt; de herstelpunten worden bewaard tot {date} en daarna verwijderd.",
       "upsell": "Beschikbaar met Pro: back-ups elk uur door Torqvoice, met herstelpunten waarnaar u terug kunt gaan.",
       "planLink": "Abonnementen bekijken",
-      "pointsTitle": "Herstelpunten",
       "pointsEmpty": "De eerste back-up is nog niet gemaakt.",
-      "showAll": "Alle {count} tonen",
-      "showFewer": "Minder tonen",
       "needsOwner": "Alleen de eigenaar van de werkplaats kan terugzetten.",
+      "listTitle": "Back-ups",
+      "today": "Vandaag",
+      "yesterday": "Gisteren",
+      "dayCount": "{count, plural, one {# back-up} other {# back-ups}}",
+      "latest": "Nieuwste",
+      "loadingMore": "Meer back-ups laden…",
+      "endOfList": "{count, plural, one {# back-up in totaal} other {# back-ups in totaal}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Back-up in de wachtrij",
+          "running": "Back-up bezig",
+          "done": "Back-up gemaakt",
+          "failed": "Back-up mislukt",
+          "skipped": "Back-up overgeslagen"
+        },
+        "restore": {
+          "queued": "Terugzetten naar {time} in de wachtrij",
+          "running": "Bezig met terugzetten naar {time}",
+          "done": "Teruggezet naar {time}",
+          "failed": "Terugzetten naar {time} mislukt",
+          "skipped": "Terugzetten naar {time} overgeslagen"
+        }
+      },
       "sizeNote": "Elk herstelpunt is op zichzelf compleet; de grootte is alles wat het omvat. De opslag bewaart elk stuk één keer en latere runs voegen alleen toe wat is veranderd, daarom blijft ‘Opgeslagen door Torqvoice’ ver onder de som van de groottes.",
-      "recentTitle": "Recente runs",
-      "noRuns": "Er zijn nog geen back-ups gemaakt.",
-      "notStarted": "Niet gestart",
       "supportReason": "Teruggezet door Torqvoice-support: {reason}",
-      "columns": {
-        "when": "Wanneer",
-        "kind": "Soort",
-        "size": "Grootte",
-        "status": "Status",
-        "trigger": "Gestart door",
-        "duration": "Duur"
-      },
       "kind": {
         "scheduled": "Gepland",
         "manual": "Handmatig",
         "safety": "Veiligheidskopie",
         "safetyHint": "Vlak voor een terugzetting gemaakt, zodat die ongedaan kan worden gemaakt"
       },
-      "trigger": {
-        "scheduled": "Gepland",
-        "manual": "Handmatig",
-        "restore": "Terugzetten"
-      },
-      "status": {
-        "queued": "In de wachtrij",
-        "running": "Bezig",
-        "done": "Klaar",
-        "failed": "Mislukt",
-        "skipped": "Overgeslagen"
-      },
       "explore": "Bekijken",
       "restore": {
         "action": "Terugzetten",

+ 23 - 26
messages/pl/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Twój plan nie obejmuje już kopii zapasowych Torqvoice. Uruchomienia zostały wstrzymane; punkty przywracania są przechowywane do {date}, a potem zostaną usunięte.",
       "upsell": "Dostępne w planie Pro: cogodzinne kopie zapasowe wykonywane przez Torqvoice z punktami przywracania, do których możesz wrócić.",
       "planLink": "Zobacz plany",
-      "pointsTitle": "Punkty przywracania",
       "pointsEmpty": "Pierwsza kopia zapasowa nie została jeszcze utworzona.",
-      "showAll": "Pokaż wszystkie ({count})",
-      "showFewer": "Pokaż mniej",
       "needsOwner": "Tylko właściciel warsztatu może przywracać.",
+      "listTitle": "Kopie zapasowe",
+      "today": "Dzisiaj",
+      "yesterday": "Wczoraj",
+      "dayCount": "{count, plural, one {# kopia} few {# kopie} many {# kopii} other {# kopii}}",
+      "latest": "Najnowsza",
+      "loadingMore": "Wczytywanie kolejnych kopii…",
+      "endOfList": "{count, plural, one {Łącznie # kopia} few {Łącznie # kopie} many {Łącznie # kopii} other {Łącznie # kopii}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Kopia w kolejce",
+          "running": "Tworzenie kopii",
+          "done": "Kopia utworzona",
+          "failed": "Kopia nieudana",
+          "skipped": "Kopia pominięta"
+        },
+        "restore": {
+          "queued": "Przywracanie do {time} w kolejce",
+          "running": "Przywracanie do {time}",
+          "done": "Przywrócono do {time}",
+          "failed": "Nie udało się przywrócić do {time}",
+          "skipped": "Przywracanie do {time} pominięte"
+        }
+      },
       "sizeNote": "Każdy punkt przywracania jest kompletny sam w sobie; jego rozmiar to wszystko, co obejmuje. Repozytorium przechowuje każdy fragment tylko raz, a kolejne uruchomienia dodają tylko to, co się zmieniło, dlatego „Przechowywane przez Torqvoice” pozostaje znacznie poniżej sumy rozmiarów.",
-      "recentTitle": "Ostatnie uruchomienia",
-      "noRuns": "Nie utworzono jeszcze żadnej kopii zapasowej.",
-      "notStarted": "Nie rozpoczęto",
       "supportReason": "Przywrócone przez zespół pomocy Torqvoice: {reason}",
-      "columns": {
-        "when": "Kiedy",
-        "kind": "Rodzaj",
-        "size": "Rozmiar",
-        "status": "Status",
-        "trigger": "Uruchomione przez",
-        "duration": "Czas trwania"
-      },
       "kind": {
         "scheduled": "Zaplanowana",
         "manual": "Ręczna",
         "safety": "Kopia bezpieczeństwa",
         "safetyHint": "Utworzona tuż przed przywróceniem, aby można je było cofnąć"
       },
-      "trigger": {
-        "scheduled": "Harmonogram",
-        "manual": "Ręcznie",
-        "restore": "Przywracanie"
-      },
-      "status": {
-        "queued": "W kolejce",
-        "running": "W toku",
-        "done": "Gotowe",
-        "failed": "Nieudane",
-        "skipped": "Pominięte"
-      },
       "explore": "Przeglądaj",
       "restore": {
         "action": "Przywróć",

+ 23 - 26
messages/pt-BR/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Seu plano não inclui mais os backups do Torqvoice. As execuções pararam; os pontos de restauração ficam guardados até {date} e depois são excluídos.",
       "upsell": "Disponível no Pro: backups de hora em hora feitos pelo Torqvoice, com pontos de restauração para os quais você pode voltar.",
       "planLink": "Ver planos",
-      "pointsTitle": "Pontos de restauração",
       "pointsEmpty": "O primeiro backup ainda não foi feito.",
-      "showAll": "Mostrar todos os {count}",
-      "showFewer": "Mostrar menos",
       "needsOwner": "Só o proprietário da oficina pode restaurar.",
+      "listTitle": "Backups",
+      "today": "Hoje",
+      "yesterday": "Ontem",
+      "dayCount": "{count, plural, one {# backup} other {# backups}}",
+      "latest": "Mais recente",
+      "loadingMore": "Carregando mais backups…",
+      "endOfList": "{count, plural, one {# backup no total} other {# backups no total}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Backup na fila",
+          "running": "Fazendo backup",
+          "done": "Backup feito",
+          "failed": "O backup falhou",
+          "skipped": "Backup ignorado"
+        },
+        "restore": {
+          "queued": "Restauração para {time} na fila",
+          "running": "Restaurando para {time}",
+          "done": "Restaurado para {time}",
+          "failed": "A restauração para {time} falhou",
+          "skipped": "Restauração para {time} ignorada"
+        }
+      },
       "sizeNote": "Cada ponto de restauração é completo por si só; seu tamanho é tudo o que ele abrange. O repositório guarda cada parte uma única vez e as execuções seguintes adicionam só o que mudou, por isso “Armazenado pelo Torqvoice” fica bem abaixo da soma dos tamanhos.",
-      "recentTitle": "Execuções recentes",
-      "noRuns": "Nenhum backup foi feito ainda.",
-      "notStarted": "Não iniciado",
       "supportReason": "Restaurado pelo suporte da Torqvoice: {reason}",
-      "columns": {
-        "when": "Quando",
-        "kind": "Tipo",
-        "size": "Tamanho",
-        "status": "Status",
-        "trigger": "Iniciado por",
-        "duration": "Duração"
-      },
       "kind": {
         "scheduled": "Agendado",
         "manual": "Manual",
         "safety": "Cópia de proteção",
         "safetyHint": "Feita logo antes de uma restauração, para que ela possa ser desfeita"
       },
-      "trigger": {
-        "scheduled": "Agendado",
-        "manual": "Manual",
-        "restore": "Restauração"
-      },
-      "status": {
-        "queued": "Na fila",
-        "running": "Em andamento",
-        "done": "Concluído",
-        "failed": "Falhou",
-        "skipped": "Ignorado"
-      },
       "explore": "Explorar",
       "restore": {
         "action": "Restaurar",

+ 23 - 26
messages/pt-PT/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "O seu plano já não inclui as cópias de segurança do Torqvoice. As execuções pararam; os pontos de restauro são guardados até {date} e depois eliminados.",
       "upsell": "Disponível no Pro: cópias de segurança de hora a hora feitas pelo Torqvoice, com pontos de restauro aos quais pode voltar.",
       "planLink": "Ver planos",
-      "pointsTitle": "Pontos de restauro",
       "pointsEmpty": "A primeira cópia de segurança ainda não foi feita.",
-      "showAll": "Mostrar todos os {count}",
-      "showFewer": "Mostrar menos",
       "needsOwner": "Só o proprietário da oficina pode repor.",
+      "listTitle": "Cópias de segurança",
+      "today": "Hoje",
+      "yesterday": "Ontem",
+      "dayCount": "{count, plural, one {# cópia} other {# cópias}}",
+      "latest": "Mais recente",
+      "loadingMore": "A carregar mais cópias…",
+      "endOfList": "{count, plural, one {# cópia no total} other {# cópias no total}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Cópia na fila",
+          "running": "A fazer cópia de segurança",
+          "done": "Cópia feita",
+          "failed": "A cópia falhou",
+          "skipped": "Cópia ignorada"
+        },
+        "restore": {
+          "queued": "Reposição para {time} na fila",
+          "running": "A repor para {time}",
+          "done": "Reposto para {time}",
+          "failed": "A reposição para {time} falhou",
+          "skipped": "Reposição para {time} ignorada"
+        }
+      },
       "sizeNote": "Cada ponto de restauro está completo por si só; o seu tamanho é tudo o que abrange. O repositório guarda cada parte uma única vez e as execuções seguintes acrescentam só o que mudou, por isso «Guardado pelo Torqvoice» fica muito abaixo da soma dos tamanhos.",
-      "recentTitle": "Execuções recentes",
-      "noRuns": "Ainda não foi feita nenhuma cópia de segurança.",
-      "notStarted": "Não iniciada",
       "supportReason": "Reposta pelo suporte do Torqvoice: {reason}",
-      "columns": {
-        "when": "Quando",
-        "kind": "Tipo",
-        "size": "Tamanho",
-        "status": "Estado",
-        "trigger": "Iniciada por",
-        "duration": "Duração"
-      },
       "kind": {
         "scheduled": "Agendada",
         "manual": "Manual",
         "safety": "Cópia de proteção",
         "safetyHint": "Feita imediatamente antes de uma reposição, para que possa ser anulada"
       },
-      "trigger": {
-        "scheduled": "Agendada",
-        "manual": "Manual",
-        "restore": "Reposição"
-      },
-      "status": {
-        "queued": "Na fila",
-        "running": "Em curso",
-        "done": "Concluída",
-        "failed": "Falhou",
-        "skipped": "Ignorada"
-      },
       "explore": "Explorar",
       "restore": {
         "action": "Repor",

+ 23 - 26
messages/ru/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Ваш тариф больше не включает резервные копии от Torqvoice. Запуски остановлены; точки восстановления хранятся до {date}, а затем удаляются.",
       "upsell": "Доступно в тарифе Pro: ежечасные резервные копии от Torqvoice с точками восстановления, к которым можно вернуться.",
       "planLink": "Посмотреть тарифы",
-      "pointsTitle": "Точки восстановления",
       "pointsEmpty": "Первая резервная копия ещё не создавалась.",
-      "showAll": "Показать все ({count})",
-      "showFewer": "Показать меньше",
       "needsOwner": "Восстановить может только владелец мастерской.",
+      "listTitle": "Резервные копии",
+      "today": "Сегодня",
+      "yesterday": "Вчера",
+      "dayCount": "{count, plural, one {# копия} few {# копии} many {# копий} other {# копии}}",
+      "latest": "Последняя",
+      "loadingMore": "Загружаем ещё копии…",
+      "endOfList": "{count, plural, one {Всего # копия} few {Всего # копии} many {Всего # копий} other {Всего # копии}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Копия в очереди",
+          "running": "Создаётся копия",
+          "done": "Копия создана",
+          "failed": "Не удалось создать копию",
+          "skipped": "Копия пропущена"
+        },
+        "restore": {
+          "queued": "Восстановление состояния на {time} в очереди",
+          "running": "Восстанавливается состояние на {time}",
+          "done": "Восстановлено состояние на {time}",
+          "failed": "Не удалось восстановить состояние на {time}",
+          "skipped": "Восстановление состояния на {time} пропущено"
+        }
+      },
       "sizeNote": "Каждая точка восстановления полна сама по себе; её размер — всё, что она охватывает. Хранилище держит каждый фрагмент один раз, а последующие запуски добавляют только изменившееся, поэтому «Хранится у Torqvoice» остаётся намного меньше суммы размеров.",
-      "recentTitle": "Последние запуски",
-      "noRuns": "Резервные копии ещё не создавались.",
-      "notStarted": "Не запущено",
       "supportReason": "Восстановлено службой поддержки Torqvoice: {reason}",
-      "columns": {
-        "when": "Когда",
-        "kind": "Тип",
-        "size": "Размер",
-        "status": "Статус",
-        "trigger": "Кто запустил",
-        "duration": "Длительность"
-      },
       "kind": {
         "scheduled": "По расписанию",
         "manual": "Вручную",
         "safety": "Страховочная копия",
         "safetyHint": "Создана непосредственно перед восстановлением, чтобы его можно было отменить"
       },
-      "trigger": {
-        "scheduled": "По расписанию",
-        "manual": "Вручную",
-        "restore": "Восстановление"
-      },
-      "status": {
-        "queued": "В очереди",
-        "running": "Выполняется",
-        "done": "Готово",
-        "failed": "Ошибка",
-        "skipped": "Пропущено"
-      },
       "explore": "Просмотреть",
       "restore": {
         "action": "Восстановить",

+ 23 - 26
messages/tr/settings.json

@@ -1478,42 +1478,39 @@
       "paused": "Planınız artık Torqvoice yedeklerini içermiyor. Çalıştırmalar durduruldu; geri yükleme noktaları {date} tarihine kadar saklanır ve ardından silinir.",
       "upsell": "Pro'da mevcut: Torqvoice tarafından alınan saatlik yedekler ve geri dönebileceğiniz geri yükleme noktaları.",
       "planLink": "Planları görün",
-      "pointsTitle": "Geri yükleme noktaları",
       "pointsEmpty": "İlk yedekleme henüz çalışmadı.",
-      "showAll": "Tümünü göster ({count})",
-      "showFewer": "Daha az göster",
       "needsOwner": "Yalnızca atölyenin sahibi geri yükleyebilir.",
+      "listTitle": "Yedekler",
+      "today": "Bugün",
+      "yesterday": "Dün",
+      "dayCount": "{count, plural, one {# yedek} other {# yedek}}",
+      "latest": "En yeni",
+      "loadingMore": "Daha fazla yedek yükleniyor…",
+      "endOfList": "{count, plural, one {Toplam # yedek} other {Toplam # yedek}}",
+      "runTitle": {
+        "backup": {
+          "queued": "Yedekleme kuyrukta",
+          "running": "Yedekleniyor",
+          "done": "Yedeklendi",
+          "failed": "Yedekleme başarısız",
+          "skipped": "Yedekleme atlandı"
+        },
+        "restore": {
+          "queued": "{time} noktasına geri yükleme kuyrukta",
+          "running": "{time} noktasına geri yükleniyor",
+          "done": "{time} noktasına geri yüklendi",
+          "failed": "{time} noktasına geri yükleme başarısız",
+          "skipped": "{time} noktasına geri yükleme atlandı"
+        }
+      },
       "sizeNote": "Her geri yükleme noktası kendi başına eksiksizdir; boyutu kapsadığı her şeydir. Depo her parçayı yalnızca bir kez saklar ve sonraki çalıştırmalar yalnızca değişenleri ekler; bu yüzden “Torqvoice tarafından depolanan” boyutların toplamının çok altında kalır.",
-      "recentTitle": "Son çalıştırmalar",
-      "noRuns": "Henüz hiç yedekleme çalışmadı.",
-      "notStarted": "Başlamadı",
       "supportReason": "Torqvoice destek ekibi tarafından geri yüklendi: {reason}",
-      "columns": {
-        "when": "Zaman",
-        "kind": "Tür",
-        "size": "Boyut",
-        "status": "Durum",
-        "trigger": "Başlatan",
-        "duration": "Süre"
-      },
       "kind": {
         "scheduled": "Zamanlanmış",
         "manual": "Elle",
         "safety": "Güvenlik kopyası",
         "safetyHint": "Geri alınabilmesi için bir geri yüklemeden hemen önce alındı"
       },
-      "trigger": {
-        "scheduled": "Zamanlanmış",
-        "manual": "Elle",
-        "restore": "Geri yükleme"
-      },
-      "status": {
-        "queued": "Kuyrukta",
-        "running": "Çalışıyor",
-        "done": "Tamamlandı",
-        "failed": "Başarısız",
-        "skipped": "Atlandı"
-      },
       "explore": "İncele",
       "restore": {
         "action": "Geri yükle",

+ 524 - 223
src/features/integrations/Components/PlatformBackupsCard.tsx

@@ -1,22 +1,26 @@
 'use client'
 
-import { type ReactNode, useEffect, useState } from 'react'
+import { type ComponentType, type ReactNode, useEffect, useRef, useState } from 'react'
 import Link from 'next/link'
 import { useRouter } from 'next/navigation'
 import { useFormatter, useTranslations } from 'next-intl'
-import { DatabaseBackup, Loader2 } from 'lucide-react'
+import {
+  CalendarClock,
+  CheckCircle2,
+  Clock,
+  DatabaseBackup,
+  HardDrive,
+  History,
+  Loader2,
+  Lock,
+  MinusCircle,
+  RotateCcw,
+  ShieldCheck,
+  XCircle,
+} from 'lucide-react'
 import { toast } from 'sonner'
 import { AppCard } from '@/components/app-card'
-import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeader,
-  TableRow,
-} from '@/components/ui/table'
 import { handleGated } from '@/components/upgrade-gate'
 import { getPlatformBackupStatus } from '@/features/integrations/Actions/getPlatformBackupStatus'
 import { runPlatformBackupNow } from '@/features/integrations/Actions/runPlatformBackupNow'
@@ -24,34 +28,70 @@ import { PlatformRestoreDialog } from '@/features/integrations/Components/Platfo
 import { useActionErrorText } from '@/features/integrations/Hooks/useBackupSnapshots'
 import { formatBytes } from '@/features/integrations/Lib/format-bytes'
 import type {
-  BackupRunStatus,
   BackupRunView,
   PlatformBackupOverview,
   PlatformSnapshotView,
 } from '@/features/integrations/Lib/backup-types'
 import type { ReauthRequirement } from '@/lib/reauth'
+import { cn } from '@/lib/utils'
 
 /** Where a workshop changes its plan; platform backups exist on the cloud only. */
 const PLAN_HREF = '/settings/subscription'
 
-/** Restore points listed before Show all; the repository keeps a few dozen. */
-const POINTS_SHOWN = 10
+/** Backups the list shows at first, and adds each time it is scrolled to its end. */
+const PAGE_SIZE = 20
+
+/**
+ * One line of the list: a restore point, with the run that took it when that
+ * run is among the last ten, or a run that left no restore point behind (one
+ * in progress, one that failed, or a restore).
+ */
+type BackupEntry =
+  | { type: 'point'; key: string; time: string; point: PlatformSnapshotView }
+  | { type: 'run'; key: string; time: string | null; run: BackupRunView }
 
-function statusClass(status: BackupRunStatus): string {
-  if (status === 'failed') return 'border-destructive/30 text-destructive'
-  if (status === 'done') return 'border-emerald-500/30 text-emerald-600'
-  if (status === 'running') return 'border-primary/30 text-primary'
-  return 'text-muted-foreground'
+/**
+ * Every restore point and the runs worth seeing beside them, newest first.
+ * A backup run that finished is its restore point, so it is not listed twice.
+ * The stored points are refreshed after each backup: a finished run newer
+ * than the newest point is one whose refresh failed, and shows as a run until
+ * the next backup lists it; an older one missing from the points was let go
+ * by the retention, and is left out with it.
+ */
+function backupEntries(overview: PlatformBackupOverview): BackupEntry[] {
+  const entries: BackupEntry[] = overview.snapshots.map((point) => ({
+    type: 'point',
+    key: `point:${point.id}`,
+    time: point.time,
+    point,
+  }))
+  const listed = new Set(overview.snapshots.map((p) => p.id))
+  const newestPoint = Math.max(...overview.snapshots.map((p) => Date.parse(p.time)))
+  for (const run of overview.recent) {
+    // A run the server gave up on before it started has only its finish.
+    const time = run.startedAt ?? run.finishedAt
+    if (run.trigger !== 'restore' && run.status === 'done') {
+      const unlisted = !(run.snapshotId && listed.has(run.snapshotId))
+      if (!unlisted || !time || Date.parse(time) <= newestPoint) continue
+    }
+    entries.push({ type: 'run', key: `run:${run.id}`, time, run })
+  }
+  // A queued run has no time yet; it is the newest thing there is.
+  const at = (e: BackupEntry) => (e.time ? Date.parse(e.time) : Number.POSITIVE_INFINITY)
+  return entries.sort((a, b) => at(b) - at(a))
 }
 
 /**
  * Backups by Torqvoice, on Settings, Data of the cloud instance: when the
- * last good one ran, when the next is due, the restore points the workshop
- * can go back to and the last runs. On a plan without them it says so, and
- * while a paused repository waits out its grace period it says until when.
- * While a backup or a restore is in progress the card asks for its own data
- * every ten seconds, which works during a restore when the rest of the page's
- * data is refused, and refreshes the page once the run is done.
+ * last good one ran, when the next is due, and one list of the workshop's
+ * backups by day, each restore point with its Explore and Restore actions
+ * and the runs that left none (in progress, failed, or a restore) in their
+ * place. The list shows twenty and adds twenty more each time it is scrolled
+ * to its end. On a plan without them it says so, and while a paused
+ * repository waits out its grace period it says until when. While a backup
+ * or a restore is in progress the card asks for its own data every ten
+ * seconds, which works during a restore when the rest of the page's data is
+ * refused, and refreshes the page once the run is done.
  */
 export function PlatformBackupsCard({
   overview: initial,
@@ -68,7 +108,6 @@ export function PlatformBackupsCard({
   const router = useRouter()
   const errorText = useActionErrorText()
   const [busy, setBusy] = useState(false)
-  const [showAll, setShowAll] = useState(false)
   const [target, setTarget] = useState<PlatformSnapshotView | null>(null)
   // What the polling read, for as long as the page has not sent newer data.
   const [live, setLive] = useState<{
@@ -106,22 +145,6 @@ export function PlatformBackupsCard({
   const when = (iso: string) =>
     format.dateTime(new Date(iso), { dateStyle: 'medium', timeStyle: 'short' })
 
-  const duration = (run: BackupRunView): string => {
-    if (!run.startedAt) return '-'
-    const end = run.finishedAt ? new Date(run.finishedAt) : run.status === 'running' ? now : null
-    if (!end) return '-'
-    const seconds = Math.max(
-      0,
-      Math.round((end.getTime() - new Date(run.startedAt).getTime()) / 1000)
-    )
-    const unit = (value: number, u: 'hour' | 'minute' | 'second') =>
-      format.number(value, { style: 'unit', unit: u, unitDisplay: 'narrow' })
-    if (seconds < 60) return unit(seconds, 'second')
-    const minutes = Math.floor(seconds / 60)
-    if (minutes < 60) return `${unit(minutes, 'minute')} ${unit(seconds % 60, 'second')}`
-    return `${unit(Math.floor(minutes / 60), 'hour')} ${unit(minutes % 60, 'minute')}`
-  }
-
   const planLink = (
     <Link href={PLAN_HREF} className="font-medium text-primary underline-offset-2 hover:underline">
       {t('planLink')}
@@ -160,10 +183,8 @@ export function PlatformBackupsCard({
         : t('queued')
       : t('running')
 
-  const points = [...overview.snapshots].sort((a, b) => Date.parse(b.time) - Date.parse(a.time))
-  const shownPoints = showAll ? points : points.slice(0, POINTS_SHOWN)
-  const mayRestore = overview.canRestore && !overview.running
-  const runs = overview.recent.slice(0, 10)
+  const entries = backupEntries(overview)
+  const pointCount = overview.snapshots.length
 
   return (
     <AppCard
@@ -182,40 +203,33 @@ export function PlatformBackupsCard({
         </Button>
       }
       contentClassName="space-y-4"
+      footer={pointCount > 0 ? t('sizeNote') : undefined}
     >
-      <div className="grid gap-4 text-sm sm:grid-cols-3">
-        <div>
-          <p className="text-xs text-muted-foreground">{t('lastGood')}</p>
-          <p className="flex flex-wrap items-center gap-2 font-medium">
-            <span suppressHydrationWarning>
-              {overview.lastGoodAt
-                ? format.relativeTime(new Date(overview.lastGoodAt), now)
-                : t('never')}
+      <dl className="grid gap-3 sm:grid-cols-3">
+        <Stat
+          icon={CheckCircle2}
+          tone={overview.lastGoodAt ? 'good' : 'muted'}
+          label={t('lastGood')}
+        >
+          <span suppressHydrationWarning>
+            {overview.lastGoodAt
+              ? format.relativeTime(new Date(overview.lastGoodAt), now)
+              : t('never')}
+          </span>
+          {overview.running && (
+            <span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary ring-1 ring-inset ring-primary/20">
+              <Loader2 className="h-3 w-3 animate-spin" />
+              {runningLabel}
             </span>
-            {overview.running && (
-              <Badge
-                variant="outline"
-                className="border-primary/30 text-[11px] font-normal text-primary"
-              >
-                <Loader2 className="mr-1 h-3 w-3 animate-spin" />
-                {runningLabel}
-              </Badge>
-            )}
-          </p>
-        </div>
-        <div>
-          <p className="text-xs text-muted-foreground">{t('nextRun')}</p>
-          <p className="font-medium">
-            {overview.nextRunAt ? when(overview.nextRunAt) : t('notScheduled')}
-          </p>
-        </div>
-        <div>
-          <p className="text-xs text-muted-foreground">{t('repositorySize')}</p>
-          <p className="font-medium">
-            {overview.repositorySize == null ? '-' : formatBytes(overview.repositorySize)}
-          </p>
-        </div>
-      </div>
+          )}
+        </Stat>
+        <Stat icon={CalendarClock} label={t('nextRun')}>
+          {overview.nextRunAt ? when(overview.nextRunAt) : t('notScheduled')}
+        </Stat>
+        <Stat icon={HardDrive} label={t('repositorySize')}>
+          {overview.repositorySize == null ? '-' : formatBytes(overview.repositorySize)}
+        </Stat>
+      </dl>
 
       {overview.restoring ? (
         <p className="rounded-md bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400">
@@ -225,126 +239,15 @@ export function PlatformBackupsCard({
         overview.running && <p className="text-xs text-muted-foreground">{t('runningHint')}</p>
       )}
 
-      <div className="space-y-2">
-        <p className="text-sm font-medium">{t('pointsTitle')}</p>
-        {points.length === 0 ? (
-          <p className="text-sm text-muted-foreground">{t('pointsEmpty')}</p>
-        ) : (
-          <div className="overflow-hidden rounded-lg border">
-            <Table>
-              <TableHeader>
-                <TableRow>
-                  <TableHead>{t('columns.when')}</TableHead>
-                  <TableHead>{t('columns.kind')}</TableHead>
-                  <TableHead className="text-right">{t('columns.size')}</TableHead>
-                  <TableHead className="w-px" />
-                </TableRow>
-              </TableHeader>
-              <TableBody>
-                {shownPoints.map((point) => (
-                  <TableRow key={point.id}>
-                    <TableCell>{when(point.time)}</TableCell>
-                    <TableCell className="text-muted-foreground">
-                      {point.preRestore ? (
-                        <>
-                          <span className="text-foreground">{t('kind.safety')}</span>
-                          <span className="block text-xs whitespace-normal">
-                            {t('kind.safetyHint')}
-                          </span>
-                        </>
-                      ) : point.trigger === 'manual' ? (
-                        t('kind.manual')
-                      ) : (
-                        t('kind.scheduled')
-                      )}
-                    </TableCell>
-                    <TableCell className="text-right tabular-nums">
-                      {point.bytes == null ? '-' : formatBytes(point.bytes)}
-                    </TableCell>
-                    <TableCell className="text-right">
-                      <div className="flex justify-end gap-2">
-                        {/* Open to look inside; the page refuses anyone but an admin. */}
-                        <Button variant="outline" size="sm" asChild>
-                          <Link href={`/settings/data/restore-points/${point.id}?source=platform`}>
-                            {t('explore')}
-                          </Link>
-                        </Button>
-                        <Button
-                          variant="outline"
-                          size="sm"
-                          className="text-destructive hover:text-destructive"
-                          onClick={() => setTarget(point)}
-                          disabled={!mayRestore}
-                        >
-                          {t('restore.action')}
-                        </Button>
-                      </div>
-                    </TableCell>
-                  </TableRow>
-                ))}
-              </TableBody>
-            </Table>
-          </div>
-        )}
-        {points.length > 0 && <p className="text-xs text-muted-foreground">{t('sizeNote')}</p>}
-        {(points.length > POINTS_SHOWN || !overview.canRestore) && (
-          <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
-            {points.length > POINTS_SHOWN && (
-              <Button
-                variant="ghost"
-                size="sm"
-                className="h-7 px-2"
-                onClick={() => setShowAll((v) => !v)}
-              >
-                {showAll ? t('showFewer') : t('showAll', { count: points.length })}
-              </Button>
-            )}
-            {!overview.canRestore && points.length > 0 && <span>{t('needsOwner')}</span>}
-          </div>
-        )}
-      </div>
-
-      <div className="space-y-2">
-        <p className="text-sm font-medium">{t('recentTitle')}</p>
-        {runs.length === 0 ? (
-          <p className="text-sm text-muted-foreground">{t('noRuns')}</p>
-        ) : (
-          <div className="overflow-hidden rounded-lg border">
-            <Table>
-              <TableHeader>
-                <TableRow>
-                  <TableHead>{t('columns.status')}</TableHead>
-                  <TableHead>{t('columns.when')}</TableHead>
-                  <TableHead>{t('columns.trigger')}</TableHead>
-                  <TableHead className="text-right">{t('columns.duration')}</TableHead>
-                </TableRow>
-              </TableHeader>
-              <TableBody>
-                {runs.map((run) => (
-                  <RunRows
-                    key={run.id}
-                    reason={
-                      run.trigger === 'restore' && run.reason
-                        ? t('supportReason', { reason: run.reason })
-                        : null
-                    }
-                    error={run.error}
-                  >
-                    <TableCell>
-                      <RunStatus status={run.status} />
-                    </TableCell>
-                    <TableCell>{run.startedAt ? when(run.startedAt) : t('notStarted')}</TableCell>
-                    <TableCell className="text-muted-foreground">
-                      {t(`trigger.${run.trigger}`)}
-                    </TableCell>
-                    <TableCell className="text-right tabular-nums">{duration(run)}</TableCell>
-                  </RunRows>
-                ))}
-              </TableBody>
-            </Table>
-          </div>
-        )}
-      </div>
+      <BackupList
+        entries={entries}
+        snapshots={overview.snapshots}
+        pointCount={pointCount}
+        canRestore={overview.canRestore}
+        mayRestore={overview.canRestore && !overview.running}
+        now={now}
+        onRestore={setTarget}
+      />
 
       <PlatformRestoreDialog
         workshopName={overview.workshopName}
@@ -356,38 +259,436 @@ export function PlatformBackupsCard({
   )
 }
 
-function RunStatus({ status }: { status: BackupRunStatus }) {
-  const t = useTranslations('settings.data.platformBackups.status')
+type Tone = 'good' | 'muted'
+
+/** One figure in the strip above the list. */
+function Stat({
+  icon: Icon,
+  label,
+  tone = 'muted',
+  children,
+}: {
+  icon: ComponentType<{ className?: string }>
+  label: string
+  tone?: Tone
+  children: ReactNode
+}) {
   return (
-    <Badge variant="outline" className={`text-[11px] font-normal ${statusClass(status)}`}>
-      {status === 'running' && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
-      {t(status)}
-    </Badge>
+    <div className="flex items-start gap-3 rounded-lg border border-card-edge/70 bg-muted/30 px-3.5 py-3">
+      <div
+        className={cn(
+          'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset',
+          tone === 'good'
+            ? 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:text-emerald-400'
+            : 'bg-background text-muted-foreground ring-border'
+        )}
+      >
+        <Icon className="h-4 w-4" />
+      </div>
+      <div className="min-w-0">
+        <dt className="text-xs text-muted-foreground">{label}</dt>
+        <dd className="mt-0.5 flex flex-wrap items-center gap-2 text-sm font-semibold tracking-tight">
+          {children}
+        </dd>
+      </div>
+    </div>
   )
 }
 
-/** One row, plus a full-width row under it for the support reason or the error. */
-function RunRows({
-  reason,
-  error,
-  children,
+/**
+ * The backups, grouped by day under a header that stays in view while the
+ * day scrolls past. Scrolls inside its own frame so the rest of Settings,
+ * Data stays a short scroll away, and shows the next twenty as its end
+ * comes into view.
+ */
+function BackupList({
+  entries,
+  snapshots,
+  pointCount,
+  canRestore,
+  mayRestore,
+  now,
+  onRestore,
 }: {
-  reason: string | null
-  error: string | null
-  children: ReactNode
+  entries: BackupEntry[]
+  /** Every restore point, to name the one a restore went back to. */
+  snapshots: PlatformSnapshotView[]
+  pointCount: number
+  canRestore: boolean
+  mayRestore: boolean
+  now: Date
+  onRestore: (point: PlatformSnapshotView) => void
+}) {
+  const t = useTranslations('settings.data.platformBackups')
+  const format = useFormatter()
+  const [shown, setShown] = useState(PAGE_SIZE)
+  const scrollRef = useRef<HTMLDivElement | null>(null)
+  const sentinelRef = useRef<HTMLLIElement | null>(null)
+  const hasMore = shown < entries.length
+
+  useEffect(() => {
+    const sentinel = sentinelRef.current
+    if (!sentinel || !hasMore) return
+    const observer = new IntersectionObserver(
+      (seen) => {
+        if (seen.some((e) => e.isIntersecting)) {
+          setShown((n) => Math.min(n + PAGE_SIZE, entries.length))
+        }
+      },
+      { root: scrollRef.current, rootMargin: '160px' }
+    )
+    observer.observe(sentinel)
+    // Observed again after every page, so a sentinel still in view keeps loading.
+    return () => observer.disconnect()
+  }, [hasMore, shown, entries.length])
+
+  // The day a backup belongs to, in the time zone the page formats in.
+  const dayKey = (d: Date) =>
+    format.dateTime(d, { year: 'numeric', month: '2-digit', day: '2-digit' })
+  const today = dayKey(now)
+  const yesterday = dayKey(new Date(now.getTime() - 86_400_000))
+  const dayLabel = (d: Date) => {
+    const key = dayKey(d)
+    if (key === today) return t('today')
+    if (key === yesterday) return t('yesterday')
+    return format.dateTime(d, {
+      weekday: 'long',
+      day: 'numeric',
+      month: 'long',
+      ...(d.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
+    })
+  }
+
+  const days: { key: string; label: string; entries: BackupEntry[] }[] = []
+  for (const entry of entries.slice(0, shown)) {
+    const d = entry.time ? new Date(entry.time) : now
+    const key = dayKey(d)
+    const last = days[days.length - 1]
+    if (last?.key === key) last.entries.push(entry)
+    else days.push({ key, label: dayLabel(d), entries: [entry] })
+  }
+  // The header counts the whole day, not only the part shown so far.
+  const perDay = new Map<string, number>()
+  for (const entry of entries) {
+    if (entry.type !== 'point') continue
+    const key = dayKey(new Date(entry.time))
+    perDay.set(key, (perDay.get(key) ?? 0) + 1)
+  }
+  // The newest point the schedule or a person took, not a safety copy.
+  const latest = entries.find((e) => e.type === 'point' && !e.point.preRestore)
+  const latestId = latest?.type === 'point' ? latest.point.id : null
+
+  return (
+    <section className="overflow-hidden rounded-lg border border-card-edge/80">
+      <header className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-card-edge/60 bg-muted/30 px-4 py-2.5">
+        <h4 className="flex items-center gap-2 text-sm font-semibold tracking-tight">
+          {t('listTitle')}
+          {pointCount > 0 && (
+            <span className="rounded-md bg-primary/10 px-1.5 py-px text-[11px] font-semibold tabular-nums text-primary ring-1 ring-inset ring-primary/15">
+              {pointCount}
+            </span>
+          )}
+        </h4>
+        {!canRestore && pointCount > 0 && (
+          <span className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
+            <Lock className="h-3 w-3" />
+            {t('needsOwner')}
+          </span>
+        )}
+      </header>
+
+      {entries.length === 0 ? (
+        <div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
+          <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
+            <DatabaseBackup className="h-5 w-5" />
+          </div>
+          <p className="text-sm text-muted-foreground">{t('pointsEmpty')}</p>
+        </div>
+      ) : (
+        <div ref={scrollRef} className="max-h-[30rem] overflow-y-auto overscroll-contain">
+          {days.map((day) => {
+            const count = perDay.get(day.key) ?? 0
+            return (
+              <div key={day.key}>
+                <div className="sticky top-0 z-20 flex items-center justify-between gap-3 border-b border-card-edge/60 bg-card/95 px-4 py-1.5 backdrop-blur supports-[backdrop-filter]:bg-card/80">
+                  <span
+                    className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
+                    suppressHydrationWarning
+                  >
+                    {day.label}
+                  </span>
+                  {count > 0 && (
+                    <span className="text-[11px] tabular-nums text-muted-foreground">
+                      {t('dayCount', { count })}
+                    </span>
+                  )}
+                </div>
+                <ul className="py-1">
+                  {day.entries.map((entry) =>
+                    entry.type === 'point' ? (
+                      <PointRow
+                        key={entry.key}
+                        point={entry.point}
+                        latest={entry.point.id === latestId}
+                        mayRestore={mayRestore}
+                        onRestore={onRestore}
+                      />
+                    ) : (
+                      <RunRow key={entry.key} run={entry.run} snapshots={snapshots} now={now} />
+                    )
+                  )}
+                </ul>
+              </div>
+            )
+          })}
+          <ul>
+            {hasMore ? (
+              <li
+                ref={sentinelRef}
+                className="flex items-center justify-center gap-2 border-t border-card-edge/60 px-4 py-3 text-xs text-muted-foreground"
+              >
+                <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                {t('loadingMore')}
+              </li>
+            ) : (
+              entries.length > PAGE_SIZE && (
+                <li className="border-t border-card-edge/60 px-4 py-3 text-center text-xs text-muted-foreground">
+                  {t('endOfList', { count: pointCount })}
+                </li>
+              )
+            )}
+          </ul>
+        </div>
+      )}
+    </section>
+  )
+}
+
+type ChipTone = 'good' | 'safety' | 'bad' | 'active' | 'muted'
+
+const CHIP_TONES: Record<ChipTone, string> = {
+  good: 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/25 dark:text-emerald-400',
+  safety: 'bg-amber-500/10 text-amber-600 ring-amber-500/25 dark:text-amber-400',
+  bad: 'bg-destructive/10 text-destructive ring-destructive/25',
+  active: 'bg-primary/10 text-primary ring-primary/25',
+  muted: 'bg-muted text-muted-foreground ring-border',
+}
+
+/**
+ * A row on the day's rail: the line runs through the markers from the first
+ * of the day to the last, and every part of the row lines up with the first
+ * line of text, so a second line (a hint, an error) hangs below.
+ */
+const RAIL_ROW = cn(
+  'relative flex items-start gap-x-3 px-4 py-2 transition-colors hover:bg-muted/40',
+  'before:absolute before:top-0 before:bottom-0 before:left-[29.5px] before:w-px before:bg-border',
+  'first:before:top-[22px] last:before:bottom-auto last:before:h-[22px] only:before:hidden'
+)
+
+/** The round marker on the rail; the card colour under it hides the line behind. */
+function Chip({
+  icon: Icon,
+  tone,
+  spin = false,
+}: {
+  icon: ComponentType<{ className?: string }>
+  tone: ChipTone
+  spin?: boolean
+}) {
+  return (
+    <span className="relative z-10 shrink-0 rounded-full bg-card">
+      <span
+        className={cn(
+          'flex h-7 w-7 items-center justify-center rounded-full ring-1 ring-inset',
+          CHIP_TONES[tone]
+        )}
+      >
+        <Icon className={cn('h-3.5 w-3.5', spin && 'animate-spin')} />
+      </span>
+    </span>
+  )
+}
+
+function Pill({ tone, children }: { tone: 'primary' | 'safety'; children: ReactNode }) {
+  return (
+    <span
+      className={cn(
+        'inline-flex items-center rounded-full px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset',
+        tone === 'primary' && 'bg-primary/10 text-primary ring-primary/20',
+        tone === 'safety' && 'bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-400'
+      )}
+    >
+      {children}
+    </span>
+  )
+}
+
+/** A restore point: its time, what took it, its size, and the way back to it. */
+function PointRow({
+  point,
+  latest,
+  mayRestore,
+  onRestore,
+}: {
+  point: PlatformSnapshotView
+  latest: boolean
+  mayRestore: boolean
+  onRestore: (point: PlatformSnapshotView) => void
 }) {
-  const notes = Boolean(reason || error)
+  const t = useTranslations('settings.data.platformBackups')
+  const format = useFormatter()
+  const size = point.bytes == null ? null : formatBytes(point.bytes)
+  const kind = point.preRestore
+    ? t('kind.safety')
+    : point.trigger === 'manual'
+      ? t('kind.manual')
+      : t('kind.scheduled')
+
   return (
-    <>
-      <TableRow className={notes ? 'border-b-0' : undefined}>{children}</TableRow>
-      {notes && (
-        <TableRow className="hover:bg-transparent">
-          <TableCell colSpan={4} className="space-y-0.5 pt-0 text-xs whitespace-normal break-words">
-            {reason && <p className="text-muted-foreground">{reason}</p>}
-            {error && <p className="text-destructive">{error}</p>}
-          </TableCell>
-        </TableRow>
+    <li className={cn(RAIL_ROW, 'flex-wrap gap-y-2 sm:flex-nowrap')}>
+      <Chip
+        icon={point.preRestore ? ShieldCheck : CheckCircle2}
+        tone={point.preRestore ? 'safety' : 'good'}
+      />
+      <div className="min-w-0 flex-1 pt-1">
+        <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
+          <span className="text-sm font-medium tabular-nums">
+            {format.dateTime(new Date(point.time), { timeStyle: 'short' })}
+          </span>
+          {point.preRestore ? (
+            <Pill tone="safety">{kind}</Pill>
+          ) : (
+            <span className="text-xs text-muted-foreground">{kind}</span>
+          )}
+          {latest && <Pill tone="primary">{t('latest')}</Pill>}
+          {size && (
+            <span className="text-xs tabular-nums text-muted-foreground sm:hidden">· {size}</span>
+          )}
+        </div>
+        {point.preRestore && (
+          <p className="mt-0.5 text-xs text-muted-foreground">{t('kind.safetyHint')}</p>
+        )}
+      </div>
+      {size && (
+        <span className="hidden w-20 shrink-0 pt-1.5 text-right text-xs tabular-nums text-muted-foreground sm:block">
+          {size}
+        </span>
       )}
-    </>
+      <div className="flex w-full shrink-0 gap-1.5 pl-10 sm:w-auto sm:pl-0">
+        {/* Open to look inside; the page refuses anyone but an admin. */}
+        <Button variant="ghost" size="sm" className="h-7 px-2.5 text-xs" asChild>
+          <Link href={`/settings/data/restore-points/${point.id}?source=platform`}>
+            {t('explore')}
+          </Link>
+        </Button>
+        <Button
+          variant="outline"
+          size="sm"
+          className="h-7 px-2.5 text-xs text-destructive hover:border-destructive/40 hover:bg-destructive/5 hover:text-destructive"
+          onClick={() => onRestore(point)}
+          disabled={!mayRestore}
+        >
+          <RotateCcw className="h-3 w-3" />
+          {t('restore.action')}
+        </Button>
+      </div>
+    </li>
+  )
+}
+
+/**
+ * A run that left no restore point of its own: one queued or running, a
+ * backup that failed or was skipped, a backup the stored points do not list
+ * yet, or a restore, with the point it went back to and the reason support
+ * gave for it.
+ */
+function RunRow({
+  run,
+  snapshots,
+  now,
+}: {
+  run: BackupRunView
+  snapshots: PlatformSnapshotView[]
+  now: Date
+}) {
+  const t = useTranslations('settings.data.platformBackups')
+  const format = useFormatter()
+  const isRestore = run.trigger === 'restore'
+  const at = run.startedAt ?? run.finishedAt
+
+  // What a restore went back to, by its time when the point is still kept.
+  const restoredTo = (() => {
+    if (!isRestore || !run.snapshotId) return ''
+    const point = snapshots.find((s) => s.id === run.snapshotId)
+    return point
+      ? format.dateTime(new Date(point.time), { dateStyle: 'medium', timeStyle: 'short' })
+      : run.snapshotId.slice(0, 8)
+  })()
+
+  const elapsed = (() => {
+    if (run.status !== 'running' || !run.startedAt) return null
+    const seconds = Math.max(0, Math.round((now.getTime() - Date.parse(run.startedAt)) / 1000))
+    const unit = (value: number, u: 'hour' | 'minute' | 'second') =>
+      format.number(value, { style: 'unit', unit: u, unitDisplay: 'narrow' })
+    if (seconds < 60) return unit(seconds, 'second')
+    const minutes = Math.floor(seconds / 60)
+    if (minutes < 60) return unit(minutes, 'minute')
+    return `${unit(Math.floor(minutes / 60), 'hour')} ${unit(minutes % 60, 'minute')}`
+  })()
+
+  const look: { icon: ComponentType<{ className?: string }>; tone: ChipTone; spin?: boolean } =
+    run.status === 'running'
+      ? { icon: Loader2, tone: 'active', spin: true }
+      : run.status === 'queued'
+        ? { icon: Clock, tone: 'active' }
+        : run.status === 'failed'
+          ? { icon: XCircle, tone: 'bad' }
+          : run.status === 'skipped'
+            ? { icon: MinusCircle, tone: 'muted' }
+            : isRestore
+              ? { icon: History, tone: 'active' }
+              : { icon: CheckCircle2, tone: 'good' }
+
+  const title = isRestore
+    ? t(`runTitle.restore.${run.status}`, { time: restoredTo })
+    : t(`runTitle.backup.${run.status}`)
+
+  return (
+    <li className={RAIL_ROW}>
+      <Chip icon={look.icon} tone={look.tone} spin={look.spin} />
+      <div className="min-w-0 flex-1 pt-1">
+        <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
+          {at && (
+            <span className="text-sm font-medium tabular-nums">
+              {format.dateTime(new Date(at), { timeStyle: 'short' })}
+            </span>
+          )}
+          <span
+            className={cn(
+              'text-sm',
+              run.status === 'failed' ? 'font-medium text-destructive' : 'text-foreground'
+            )}
+          >
+            {title}
+          </span>
+          {!isRestore && (
+            <span className="text-xs text-muted-foreground">
+              {run.trigger === 'manual' ? t('kind.manual') : t('kind.scheduled')}
+            </span>
+          )}
+          {elapsed && (
+            <span className="text-xs tabular-nums text-muted-foreground" suppressHydrationWarning>
+              · {elapsed}
+            </span>
+          )}
+        </div>
+        {isRestore && run.reason && (
+          <p className="mt-0.5 text-xs text-muted-foreground">
+            {t('supportReason', { reason: run.reason })}
+          </p>
+        )}
+        {run.error && <p className="mt-0.5 break-words text-xs text-destructive/90">{run.error}</p>}
+      </div>
+    </li>
   )
 }