diff --git a/AGENTS.md b/AGENTS.md index 3e0b6e8..e06a61b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -237,6 +237,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. +The project page exposes an initially collapsed German snapshot/timeline UI +with explicit restore confirmation and cursor-based loading of older revision +metadata. Insertions and generated move targets use client-generated stable UUIDs, and undo restores the same ids from complete server snapshots. The tree response supplies the optimistic `currentRevision`; obsolete direct field PATCH, @@ -308,7 +311,7 @@ Users must be able to override sizing suggestions. ## Current Deferred Work -- snapshot UI and periodic snapshot policy +- periodic snapshot and retention policy - Revit/CSV/IFCGUID round-trip - full electrical sizing - multi-user/PostgreSQL operation diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 5564571..d4d3b95 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -143,5 +143,5 @@ Covered operations include: - apply sorted order The toolbar currently exposes availability through its Undo/Redo buttons. -Named restore points can be created, listed and restored through the API. A -browsable revision/snapshot UI remains future work. +Named restore points and the revision timeline are available on the project +page; the circuit editor keeps only the compact Undo/Redo controls. diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 0bd0dc9..1732e20 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -4,11 +4,10 @@ persistent project commands and project-wide toolbar undo/redo, including moves, atomic multi-section reorders and explicit renumbering. Undo/redo eligibility is restored from server history after a page reload. -- A paginated revision-metadata API exists, but there is no browsable timeline - UI yet; the editor currently exposes only the next eligible Undo/Redo - actions. +- The project page exposes the paginated revision timeline; the circuit editor + itself currently exposes only the next eligible Undo/Redo actions. - Named logical project snapshots can be created, listed and restored through - the API with persistent Undo/Redo. Snapshot UI and periodic snapshots are + the project page with persistent Undo/Redo. Automatic periodic snapshots are not implemented yet. - No Revit/CSV/IFCGUID import and export workflow is implemented yet. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 8e960b7..94d2877 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -117,6 +117,11 @@ ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie nicht Bestandteil des logischen Snapshots sind. +Die Projektseite bindet diese APIs in einem einklappbaren Bereich +„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte +benennen, nach expliziter Bestätigung wiederherstellen und die paginierte +Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen. +Nach einem Restore lädt die Seite sämtliche Projektdaten neu. `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index b702c54..89abe2c 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -97,6 +97,9 @@ Implemented named-snapshot foundation: separately and are never imported into the logical snapshot payload - arbitrary restore payloads are rejected by the generic user-command endpoint; only server-stored snapshots can initiate a forward restore +- the project page provides an initially collapsed snapshot/timeline panel, + explicit inline restore confirmation and cursor-based loading of older + revisions; a successful restore reloads all project read models ## Revit / CSV / IFCGUID Round-Trip Direction @@ -252,7 +255,7 @@ Completed foundation: Remaining constraints before completing project version history: -- add a browsable timeline/snapshot UI and periodic snapshot policy +- define and implement a periodic snapshot and retention policy - extend project-scoped commands only when further project mutations are deliberately added to history; the generic versioned envelope is complete - do not model IFCGUID as an overloaded circuit equipment identifier diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 975f8a3..c9cfafc 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -371,6 +371,8 @@ Tasks: - add periodic logical project snapshots - [x] restore a historical snapshot as a new auditable revision with persistent undo/redo +- [x] expose named snapshots, confirmed restore and the paginated revision + timeline on the project page - keep database backups separate from logical history Implemented foundation: @@ -397,6 +399,8 @@ Implemented foundation: - named snapshot Create/List endpoints persist complete project-scoped runtime state with the source revision and SHA-256 without changing revision or undo/redo stacks +- the project page lists, creates and explicitly restores named snapshots and + displays paginated revision metadata without exposing command payloads - a central application dispatcher reconstructs persisted commands and exposes optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow field updates diff --git a/package.json b/package.json index 1bb5670..d771b0c 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index aa410ae..8790986 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -40,6 +40,7 @@ import { projectDeviceSyncFields, type ProjectDeviceSyncField, } from "../../../shared/constants/project-device-sync-fields"; +import { ProjectVersionHistory } from "../../../frontend/components/project-version-history"; const projectDeviceSyncFieldLabels: Record = { name: "Technischer Name", @@ -608,6 +609,16 @@ export default function ProjectDetailPage() { + {project ? ( +
+ window.location.reload()} + projectId={projectId} + /> +
+ ) : null} +
Neue Verteilung
diff --git a/src/frontend/components/project-version-history.tsx b/src/frontend/components/project-version-history.tsx new file mode 100644 index 0000000..63c3ea3 --- /dev/null +++ b/src/frontend/components/project-version-history.tsx @@ -0,0 +1,405 @@ +"use client"; + +import React, { + FormEvent, + useCallback, + useEffect, + useState, +} from "react"; +import type { + ProjectRevisionPageDto, + ProjectRevisionSummaryDto, + ProjectSnapshotMetadataDto, +} from "../types"; +import { + createNamedProjectSnapshot, + listProjectRevisions, + listProjectSnapshots, + restoreProjectSnapshot, +} from "../utils/api"; +import { + getProjectRevisionDescription, + getProjectRevisionSourceLabel, + mergeProjectRevisionPages, +} from "../utils/project-version-history"; + +interface ProjectVersionHistoryProps { + projectId: string; + currentRevision: number; + onRestoreComplete: () => void; +} + +const revisionPageSize = 10; + +export function ProjectVersionHistory({ + projectId, + currentRevision, + onRestoreComplete, +}: ProjectVersionHistoryProps) { + const [snapshots, setSnapshots] = useState( + [] + ); + const [revisions, setRevisions] = useState( + [] + ); + const [nextBeforeRevision, setNextBeforeRevision] = useState< + number | null + >(null); + const [snapshotName, setSnapshotName] = useState(""); + const [snapshotDescription, setSnapshotDescription] = useState(""); + const [restoreCandidate, setRestoreCandidate] = + useState(null); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(null); + + const loadOverview = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const [loadedSnapshots, revisionPage] = await Promise.all([ + listProjectSnapshots(projectId), + listProjectRevisions(projectId, { limit: revisionPageSize }), + ]); + setSnapshots(loadedSnapshots); + setRevisions(revisionPage.revisions); + setNextBeforeRevision(revisionPage.nextBeforeRevision); + } catch (loadError) { + setError( + getVersionHistoryError( + loadError, + "Versionshistorie konnte nicht geladen werden." + ) + ); + } finally { + setIsLoading(false); + } + }, [projectId]); + + useEffect(() => { + if (isOpen) { + void loadOverview(); + } + }, [loadOverview, currentRevision, isOpen]); + + async function handleCreateSnapshot(event: FormEvent) { + event.preventDefault(); + const name = snapshotName.trim(); + if (!name) { + return; + } + setIsBusy(true); + setError(null); + try { + const created = await createNamedProjectSnapshot( + projectId, + currentRevision, + name, + snapshotDescription.trim() || undefined + ); + setSnapshots((current) => [created, ...current]); + setSnapshotName(""); + setSnapshotDescription(""); + } catch (createError) { + setError( + getVersionHistoryError( + createError, + "Projektstand konnte nicht gespeichert werden." + ) + ); + } finally { + setIsBusy(false); + } + } + + async function handleLoadOlderRevisions() { + if (nextBeforeRevision === null) { + return; + } + setIsBusy(true); + setError(null); + try { + const page: ProjectRevisionPageDto = await listProjectRevisions( + projectId, + { + limit: revisionPageSize, + beforeRevision: nextBeforeRevision, + } + ); + setRevisions((current) => + mergeProjectRevisionPages(current, page.revisions) + ); + setNextBeforeRevision(page.nextBeforeRevision); + } catch (loadError) { + setError( + getVersionHistoryError( + loadError, + "Ältere Revisionen konnten nicht geladen werden." + ) + ); + } finally { + setIsBusy(false); + } + } + + async function handleRestoreSnapshot() { + if (!restoreCandidate) { + return; + } + setIsBusy(true); + setError(null); + try { + await restoreProjectSnapshot( + projectId, + restoreCandidate.id, + currentRevision + ); + setRestoreCandidate(null); + setIsBusy(false); + onRestoreComplete(); + } catch (restoreError) { + setError( + getVersionHistoryError( + restoreError, + "Projektstand konnte nicht wiederhergestellt werden." + ) + ); + setRestoreCandidate(null); + setIsBusy(false); + } + } + + return ( +
+
+ Versionen und Sicherungspunkte +
+ + Aktuelle Revision {currentRevision} + + +
+
+ {isOpen ? ( + <> +
+

Neuen Sicherungspunkt anlegen

+

+ Ein Sicherungspunkt speichert den vollständigen Projektstand, ohne + die Revision oder Rückgängig-Historie zu verändern. +

+
+
+ + setSnapshotName(event.target.value)} + /> +
+
+ + + setSnapshotDescription(event.target.value) + } + /> +
+
+ +
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+

Gespeicherte Sicherungspunkte

+ {isLoading ? ( +

Sicherungspunkte werden geladen …

+ ) : snapshots.length ? ( +
+ + + + + + + + + + + {snapshots.map((snapshot) => ( + + + + + + + ))} + +
NameProjektstandErstelltAktion
+ {snapshot.name} + {snapshot.description ? ( +
+ {snapshot.description} +
+ ) : null} +
Revision {snapshot.sourceRevision}{formatDateTime(snapshot.createdAtIso)} + +
+
+ ) : ( +

+ Noch keine Sicherungspunkte vorhanden. +

+ )} + + {restoreCandidate ? ( +
+

+ „{restoreCandidate.name}“ wirklich wiederherstellen? +

+

+ Verteiler, Stromkreise, Projektgeräte, Räume und + Projekteinstellungen werden auf Revision{" "} + {restoreCandidate.sourceRevision} zurückgesetzt. Der aktuelle + Stand bleibt als neue rückgängig machbare Revision erhalten. +

+
+ + +
+
+ ) : null} +
+ +
+

Letzte Änderungen

+ {isLoading ? ( +

Historie wird geladen …

+ ) : revisions.length ? ( + <> +
+ + + + + + + + + + + {revisions.map((revision) => ( + + + + + + + ))} + +
RevisionZeitpunktArtÄnderung
{revision.revisionNumber}{formatDateTime(revision.createdAtIso)} + {getProjectRevisionSourceLabel(revision.source)} + {getProjectRevisionDescription(revision)}
+
+ {nextBeforeRevision !== null ? ( + + ) : null} + + ) : ( +

+ Für dieses Projekt wurden noch keine versionierten Änderungen + gespeichert. +

+ )} +
+ + ) : null} +
+ ); +} + +function formatDateTime(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function getVersionHistoryError(error: unknown, fallback: string) { + const details = error instanceof Error ? error.message : ""; + if (details.includes("PROJECT_SNAPSHOT_NAME_CONFLICT")) { + return "Ein Sicherungspunkt mit diesem Namen ist bereits vorhanden."; + } + if ( + details.includes("PROJECT_REVISION_CONFLICT") || + details.includes("PROJECT_STATE_CONFLICT") + ) { + return "Das Projekt wurde inzwischen geändert. Bitte lade die Seite neu und versuche es erneut."; + } + return fallback; +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index e5adc51..8d9291b 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -15,6 +15,49 @@ export interface ProjectHistoryStateDto { redoChangeSetId: string | null; } +export type ProjectRevisionSourceDto = + | "user" + | "undo" + | "redo" + | "restore" + | "migration"; + +export interface ProjectRevisionSummaryDto { + revisionId: string; + changeSetId: string; + revisionNumber: number; + createdAtIso: string; + actorId: string | null; + source: ProjectRevisionSourceDto; + description: string | null; + commandType: string; + payloadSchemaVersion: number; +} + +export interface ProjectRevisionPageDto { + projectId: string; + currentRevision: number; + revisions: ProjectRevisionSummaryDto[]; + nextBeforeRevision: number | null; +} + +export interface ProjectSnapshotMetadataDto { + id: string; + projectId: string; + sourceRevision: number; + schemaVersion: number; + name: string; + description: string | null; + payloadSha256: string; + createdAtIso: string; + createdByActorId: string | null; +} + +export interface ProjectSnapshotRestoreResultDto + extends ProjectCommandResultDto { + snapshot: ProjectSnapshotMetadataDto; +} + export interface ProjectCommandDto { schemaVersion: number; type: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index e9bbbea..f622e29 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -12,6 +12,9 @@ import type { ProjectDeviceCommandResultDto, ProjectDeviceDto, ProjectHistoryStateDto, + ProjectRevisionPageDto, + ProjectSnapshotMetadataDto, + ProjectSnapshotRestoreResultDto, ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncPreviewDto, ProjectDto, @@ -82,6 +85,62 @@ export function getProjectHistory(projectId: string) { ); } +export function listProjectRevisions( + projectId: string, + input: { limit?: number; beforeRevision?: number } = {} +) { + const query = new URLSearchParams(); + if (input.limit !== undefined) { + query.set("limit", String(input.limit)); + } + if (input.beforeRevision !== undefined) { + query.set("beforeRevision", String(input.beforeRevision)); + } + const suffix = query.size ? `?${query.toString()}` : ""; + return request( + `/api/projects/${projectId}/history/revisions${suffix}` + ); +} + +export function listProjectSnapshots(projectId: string) { + return request( + `/api/projects/${projectId}/snapshots` + ); +} + +export function createNamedProjectSnapshot( + projectId: string, + expectedRevision: number, + name: string, + description?: string +) { + return request( + `/api/projects/${projectId}/snapshots`, + { + method: "POST", + body: JSON.stringify({ + expectedRevision, + name, + ...(description ? { description } : {}), + }), + } + ); +} + +export function restoreProjectSnapshot( + projectId: string, + snapshotId: string, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/snapshots/${snapshotId}/restore`, + { + method: "POST", + body: JSON.stringify({ expectedRevision }), + } + ); +} + export function executeProjectCommand( projectId: string, expectedRevision: number, diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts new file mode 100644 index 0000000..27da285 --- /dev/null +++ b/src/frontend/utils/project-version-history.ts @@ -0,0 +1,63 @@ +import type { + ProjectRevisionSourceDto, + ProjectRevisionSummaryDto, +} from "../types"; + +const sourceLabels: Record = { + user: "Bearbeitung", + undo: "Rückgängig", + redo: "Wiederholt", + restore: "Wiederherstellung", + migration: "Migration", +}; + +const commandTypeLabels: Record = { + "circuit.update": "Stromkreis bearbeitet", + "circuit.insert": "Stromkreis angelegt", + "circuit.delete": "Stromkreis gelöscht", + "circuit-device-row.update": "Gerätezeile bearbeitet", + "circuit-device-row.insert": "Gerätezeile angelegt", + "circuit-device-row.delete": "Gerätezeile gelöscht", + "circuit-device-row.move": "Gerätezeile verschoben", + "circuit-device-row.move-with-new-circuit": + "Gerätezeile in neuen Stromkreis verschoben", + "circuit.reorder-section": "Stromkreise sortiert", + "circuit.reorder-sections": "Stromkreise bereichsübergreifend sortiert", + "circuit.renumber-section": "Bereich neu nummeriert", + "project-device.update": "Projektgerät bearbeitet", + "project-device.insert": "Projektgerät angelegt", + "project-device.delete": "Projektgerät gelöscht", + "project-device.sync-rows": "Projektgerät-Verknüpfungen geändert", + "project.restore-state": "Projektstand wiederhergestellt", +}; + +export function getProjectRevisionSourceLabel( + source: ProjectRevisionSourceDto +) { + return sourceLabels[source]; +} + +export function getProjectRevisionDescription( + revision: ProjectRevisionSummaryDto +) { + return ( + revision.description?.trim() || + commandTypeLabels[revision.commandType] || + revision.commandType + ); +} + +export function mergeProjectRevisionPages( + current: ProjectRevisionSummaryDto[], + next: ProjectRevisionSummaryDto[] +) { + const byId = new Map( + [...current, ...next].map((revision) => [ + revision.revisionId, + revision, + ]) + ); + return [...byId.values()].sort( + (left, right) => right.revisionNumber - left.revisionNumber + ); +} diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts new file mode 100644 index 0000000..6cbeeeb --- /dev/null +++ b/tests/project-version-history.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js"; +import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js"; +import { + createNamedProjectSnapshot, + listProjectRevisions, + listProjectSnapshots, + restoreProjectSnapshot, +} from "../src/frontend/utils/api.js"; +import { + getProjectRevisionDescription, + getProjectRevisionSourceLabel, + mergeProjectRevisionPages, +} from "../src/frontend/utils/project-version-history.js"; + +function revision( + revisionNumber: number, + overrides: Partial = {} +): ProjectRevisionSummaryDto { + return { + revisionId: `revision-${revisionNumber}`, + changeSetId: `change-${revisionNumber}`, + revisionNumber, + createdAtIso: "2026-07-25T12:00:00.000Z", + actorId: null, + source: "user", + description: null, + commandType: "circuit.update", + payloadSchemaVersion: 1, + ...overrides, + }; +} + +describe("project version history presentation", () => { + it("renders a compact collapsed German entry point", () => { + const markup = renderToStaticMarkup( + createElement(ProjectVersionHistory, { + projectId: "project-1", + currentRevision: 42, + onRestoreComplete: () => undefined, + }) + ); + assert.match(markup, /Versionen und Sicherungspunkte/); + assert.match(markup, /Aktuelle Revision 42/); + assert.match(markup, /Anzeigen/); + assert.doesNotMatch(markup, /Stand speichern/); + }); + + it("uses German source and command descriptions", () => { + assert.equal(getProjectRevisionSourceLabel("user"), "Bearbeitung"); + assert.equal(getProjectRevisionSourceLabel("undo"), "Rückgängig"); + assert.equal(getProjectRevisionSourceLabel("redo"), "Wiederholt"); + assert.equal( + getProjectRevisionSourceLabel("restore"), + "Wiederherstellung" + ); + assert.equal(getProjectRevisionSourceLabel("migration"), "Migration"); + assert.equal( + getProjectRevisionDescription(revision(1)), + "Stromkreis bearbeitet" + ); + assert.equal( + getProjectRevisionDescription( + revision(2, { description: " Eigene Beschreibung " }) + ), + "Eigene Beschreibung" + ); + assert.equal( + getProjectRevisionDescription( + revision(3, { commandType: "future.command" }) + ), + "future.command" + ); + }); + + it("merges paginated revisions without duplicates in descending order", () => { + assert.deepEqual( + mergeProjectRevisionPages( + [revision(5), revision(4)], + [revision(4), revision(3)] + ).map((entry) => entry.revisionNumber), + [5, 4, 3] + ); + }); +}); + +describe("project version history API", () => { + it("uses the revision-safe timeline and snapshot routes", async () => { + const requests: Array<{ + url: string; + method: string; + body: unknown; + }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : null, + }); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await listProjectRevisions("project-1", { + limit: 10, + beforeRevision: 21, + }); + await listProjectSnapshots("project-1"); + await createNamedProjectSnapshot( + "project-1", + 22, + "Planfreigabe", + "Vor Ausführung" + ); + await restoreProjectSnapshot("project-1", "snapshot-1", 22); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(requests, [ + { + url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21", + method: "GET", + body: null, + }, + { + url: "/api/projects/project-1/snapshots", + method: "GET", + body: null, + }, + { + url: "/api/projects/project-1/snapshots", + method: "POST", + body: { + expectedRevision: 22, + name: "Planfreigabe", + description: "Vor Ausführung", + }, + }, + { + url: "/api/projects/project-1/snapshots/snapshot-1/restore", + method: "POST", + body: { expectedRevision: 22 }, + }, + ]); + }); +});