Expose project version history

This commit is contained in:
2026-07-25 22:56:51 +02:00
parent 384a5769ab
commit d00ae30bda
13 changed files with 759 additions and 10 deletions
+4 -1
View File
@@ -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
+2 -2
View File
@@ -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.
@@ -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.
+5
View File
@@ -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;
@@ -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
@@ -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
+2 -2
View File
@@ -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",
+11
View File
@@ -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<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
@@ -608,6 +609,16 @@ export default function ProjectDetailPage() {
</div>
</section>
{project ? (
<section className="col-12">
<ProjectVersionHistory
currentRevision={project.currentRevision}
onRestoreComplete={() => window.location.reload()}
projectId={projectId}
/>
</section>
) : null}
<section className="col-12 col-lg-4">
<div className="card shadow-sm">
<div className="card-header">Neue Verteilung</div>
@@ -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<ProjectSnapshotMetadataDto[]>(
[]
);
const [revisions, setRevisions] = useState<ProjectRevisionSummaryDto[]>(
[]
);
const [nextBeforeRevision, setNextBeforeRevision] = useState<
number | null
>(null);
const [snapshotName, setSnapshotName] = useState("");
const [snapshotDescription, setSnapshotDescription] = useState("");
const [restoreCandidate, setRestoreCandidate] =
useState<ProjectSnapshotMetadataDto | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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 (
<section className="card shadow-sm">
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>Versionen und Sicherungspunkte</span>
<div className="d-flex align-items-center gap-2">
<span className="badge text-bg-secondary">
Aktuelle Revision {currentRevision}
</span>
<button
aria-expanded={isOpen}
className="btn btn-sm btn-outline-secondary"
onClick={() => setIsOpen((current) => !current)}
type="button"
>
{isOpen ? "Ausblenden" : "Anzeigen"}
</button>
</div>
</div>
{isOpen ? (
<>
<div className="card-body border-bottom">
<h2 className="h6">Neuen Sicherungspunkt anlegen</h2>
<p className="small text-secondary">
Ein Sicherungspunkt speichert den vollständigen Projektstand, ohne
die Revision oder Rückgängig-Historie zu verändern.
</p>
<form className="row g-2" onSubmit={handleCreateSnapshot}>
<div className="col-12 col-lg-4">
<label className="form-label" htmlFor="snapshot-name">
Name
</label>
<input
className="form-control"
id="snapshot-name"
maxLength={100}
placeholder="z. B. Vor Planfreigabe"
value={snapshotName}
onChange={(event) => setSnapshotName(event.target.value)}
/>
</div>
<div className="col-12 col-lg-6">
<label className="form-label" htmlFor="snapshot-description">
Beschreibung (optional)
</label>
<input
className="form-control"
id="snapshot-description"
maxLength={500}
placeholder="Was kennzeichnet diesen Stand?"
value={snapshotDescription}
onChange={(event) =>
setSnapshotDescription(event.target.value)
}
/>
</div>
<div className="col-12 col-lg-2 d-flex align-items-end">
<button
className="btn btn-primary w-100"
disabled={isBusy || !snapshotName.trim()}
type="submit"
>
Stand speichern
</button>
</div>
</form>
</div>
{error ? (
<div className="alert alert-warning m-3 mb-0" role="alert">
{error}
</div>
) : null}
<div className="card-body border-bottom">
<h2 className="h6">Gespeicherte Sicherungspunkte</h2>
{isLoading ? (
<p className="text-secondary mb-0">Sicherungspunkte werden geladen </p>
) : snapshots.length ? (
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Name</th>
<th>Projektstand</th>
<th>Erstellt</th>
<th className="text-end">Aktion</th>
</tr>
</thead>
<tbody>
{snapshots.map((snapshot) => (
<tr key={snapshot.id}>
<td>
<strong>{snapshot.name}</strong>
{snapshot.description ? (
<div className="small text-secondary">
{snapshot.description}
</div>
) : null}
</td>
<td>Revision {snapshot.sourceRevision}</td>
<td>{formatDateTime(snapshot.createdAtIso)}</td>
<td className="text-end">
<button
className="btn btn-sm btn-outline-danger"
disabled={isBusy}
onClick={() => setRestoreCandidate(snapshot)}
type="button"
>
Wiederherstellen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-secondary mb-0">
Noch keine Sicherungspunkte vorhanden.
</p>
)}
{restoreCandidate ? (
<div className="alert alert-danger mt-3 mb-0" role="alert">
<h3 className="h6">
{restoreCandidate.name} wirklich wiederherstellen?
</h3>
<p className="mb-2">
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.
</p>
<div className="d-flex flex-wrap gap-2">
<button
className="btn btn-danger"
disabled={isBusy}
onClick={() => void handleRestoreSnapshot()}
type="button"
>
Jetzt wiederherstellen
</button>
<button
className="btn btn-outline-secondary"
disabled={isBusy}
onClick={() => setRestoreCandidate(null)}
type="button"
>
Abbrechen
</button>
</div>
</div>
) : null}
</div>
<div className="card-body">
<h2 className="h6">Letzte Änderungen</h2>
{isLoading ? (
<p className="text-secondary mb-0">Historie wird geladen </p>
) : revisions.length ? (
<>
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Revision</th>
<th>Zeitpunkt</th>
<th>Art</th>
<th>Änderung</th>
</tr>
</thead>
<tbody>
{revisions.map((revision) => (
<tr key={revision.revisionId}>
<td>{revision.revisionNumber}</td>
<td>{formatDateTime(revision.createdAtIso)}</td>
<td>
{getProjectRevisionSourceLabel(revision.source)}
</td>
<td>{getProjectRevisionDescription(revision)}</td>
</tr>
))}
</tbody>
</table>
</div>
{nextBeforeRevision !== null ? (
<button
className="btn btn-sm btn-outline-secondary mt-3"
disabled={isBusy}
onClick={() => void handleLoadOlderRevisions()}
type="button"
>
Ältere Änderungen laden
</button>
) : null}
</>
) : (
<p className="text-secondary mb-0">
Für dieses Projekt wurden noch keine versionierten Änderungen
gespeichert.
</p>
)}
</div>
</>
) : null}
</section>
);
}
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;
}
+43
View File
@@ -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;
+59
View File
@@ -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<ProjectRevisionPageDto>(
`/api/projects/${projectId}/history/revisions${suffix}`
);
}
export function listProjectSnapshots(projectId: string) {
return request<ProjectSnapshotMetadataDto[]>(
`/api/projects/${projectId}/snapshots`
);
}
export function createNamedProjectSnapshot(
projectId: string,
expectedRevision: number,
name: string,
description?: string
) {
return request<ProjectSnapshotMetadataDto>(
`/api/projects/${projectId}/snapshots`,
{
method: "POST",
body: JSON.stringify({
expectedRevision,
name,
...(description ? { description } : {}),
}),
}
);
}
export function restoreProjectSnapshot(
projectId: string,
snapshotId: string,
expectedRevision: number
) {
return request<ProjectSnapshotRestoreResultDto>(
`/api/projects/${projectId}/snapshots/${snapshotId}/restore`,
{
method: "POST",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function executeProjectCommand(
projectId: string,
expectedRevision: number,
@@ -0,0 +1,63 @@
import type {
ProjectRevisionSourceDto,
ProjectRevisionSummaryDto,
} from "../types";
const sourceLabels: Record<ProjectRevisionSourceDto, string> = {
user: "Bearbeitung",
undo: "Rückgängig",
redo: "Wiederholt",
restore: "Wiederherstellung",
migration: "Migration",
};
const commandTypeLabels: Record<string, string> = {
"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
);
}
+154
View File
@@ -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> = {}
): 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 },
},
]);
});
});