Add optional room areas

This commit is contained in:
Julian Appel 2026-09-27 20:00:03 +02:00
parent a6500e2b26
commit dce31ee143
26 changed files with 3669 additions and 78 deletions

View file

@ -10,7 +10,7 @@ export const projectFloorUpdateCommandType = "project-floor.update" as const;
export const projectRoomInsertCommandType = "project-room.insert" as const;
export const projectRoomDeleteCommandType = "project-room.delete" as const;
export const projectRoomUpdateCommandType = "project-room.update" as const;
/** Room commands, building commands and legacy floor commands. */
/** Building commands and legacy floor/room commands. */
export const projectLocationStructureCommandSchemaVersion = 1 as const;
/**
* Floor commands carry `buildingId` since version 2. Version 1 floor
@ -19,6 +19,9 @@ export const projectLocationStructureCommandSchemaVersion = 1 as const;
*/
export const projectFloorCommandSchemaVersion = 2 as const;
const legacyProjectFloorCommandSchemaVersion = 1 as const;
/** Room commands carry `areaM2` since version 2. */
export const projectRoomCommandSchemaVersion = 2 as const;
const legacyProjectRoomCommandSchemaVersion = 1 as const;
export interface ProjectBuildingSnapshot {
id: string;
@ -43,8 +46,11 @@ export interface ProjectRoomSnapshot {
floorId: string | null;
roomNumber: string;
roomName: string;
areaM2: number | null;
}
export type LegacyProjectRoomSnapshot = Omit<ProjectRoomSnapshot, "areaM2">;
interface ProjectBuildingStructureCommandPayload {
building: ProjectBuildingSnapshot;
}
@ -61,6 +67,10 @@ interface ProjectRoomStructureCommandPayload {
room: ProjectRoomSnapshot;
}
interface LegacyProjectRoomStructureCommandPayload {
room: LegacyProjectRoomSnapshot;
}
interface ProjectBuildingUpdateCommandPayload {
expected: ProjectBuildingSnapshot;
target: ProjectBuildingSnapshot;
@ -81,6 +91,11 @@ interface ProjectRoomUpdateCommandPayload {
target: ProjectRoomSnapshot;
}
interface LegacyProjectRoomUpdateCommandPayload {
expected: LegacyProjectRoomSnapshot;
target: LegacyProjectRoomSnapshot;
}
export interface ProjectBuildingInsertProjectCommand
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
@ -137,19 +152,37 @@ export interface LegacyProjectFloorUpdateProjectCommand
export interface ProjectRoomInsertProjectCommand
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
schemaVersion: typeof projectRoomCommandSchemaVersion;
type: typeof projectRoomInsertCommandType;
}
export interface ProjectRoomDeleteProjectCommand
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
schemaVersion: typeof projectRoomCommandSchemaVersion;
type: typeof projectRoomDeleteCommandType;
}
export interface ProjectRoomUpdateProjectCommand
extends SerializedProjectCommand<ProjectRoomUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
schemaVersion: typeof projectRoomCommandSchemaVersion;
type: typeof projectRoomUpdateCommandType;
}
export interface LegacyProjectRoomInsertProjectCommand
extends SerializedProjectCommand<LegacyProjectRoomStructureCommandPayload> {
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
type: typeof projectRoomInsertCommandType;
}
export interface LegacyProjectRoomDeleteProjectCommand
extends SerializedProjectCommand<LegacyProjectRoomStructureCommandPayload> {
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
type: typeof projectRoomDeleteCommandType;
}
export interface LegacyProjectRoomUpdateProjectCommand
extends SerializedProjectCommand<LegacyProjectRoomUpdateCommandPayload> {
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
type: typeof projectRoomUpdateCommandType;
}
@ -165,7 +198,10 @@ export type ProjectLocationStructureProjectCommand =
| LegacyProjectFloorUpdateProjectCommand
| ProjectRoomInsertProjectCommand
| ProjectRoomDeleteProjectCommand
| ProjectRoomUpdateProjectCommand;
| ProjectRoomUpdateProjectCommand
| LegacyProjectRoomInsertProjectCommand
| LegacyProjectRoomDeleteProjectCommand
| LegacyProjectRoomUpdateProjectCommand;
export function createProjectBuildingSnapshot(
projectId: string,
@ -205,6 +241,7 @@ export function createProjectRoomSnapshot(
floorId?: string;
roomNumber: string;
roomName: string;
areaM2?: number | null;
}
): ProjectRoomSnapshot {
const room: ProjectRoomSnapshot = {
@ -213,6 +250,7 @@ export function createProjectRoomSnapshot(
floorId: input.floorId?.trim() || null,
roomNumber: input.roomNumber.trim(),
roomName: input.roomName.trim(),
areaM2: input.areaM2 ?? null,
};
assertProjectRoomSnapshot(room);
return room;
@ -279,7 +317,7 @@ export function createProjectRoomInsertProjectCommand(
): ProjectRoomInsertProjectCommand {
assertProjectRoomSnapshot(room);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
schemaVersion: projectRoomCommandSchemaVersion,
type: projectRoomInsertCommandType,
payload: { room },
};
@ -290,7 +328,7 @@ export function createProjectRoomDeleteProjectCommand(
): ProjectRoomDeleteProjectCommand {
assertProjectRoomSnapshot(room);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
schemaVersion: projectRoomCommandSchemaVersion,
type: projectRoomDeleteCommandType,
payload: { room },
};
@ -314,7 +352,7 @@ export function createProjectRoomUpdateProjectCommand(
): ProjectRoomUpdateProjectCommand {
assertMatchingRoomUpdate(expected, target);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
schemaVersion: projectRoomCommandSchemaVersion,
type: projectRoomUpdateCommandType,
payload: { expected, target },
};
@ -376,7 +414,9 @@ export function assertProjectFloorDeleteProjectCommand(
export function assertProjectRoomInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectRoomInsertProjectCommand {
): asserts command is
| ProjectRoomInsertProjectCommand
| LegacyProjectRoomInsertProjectCommand {
assertProjectRoomStructureProjectCommand(
command,
projectRoomInsertCommandType
@ -385,7 +425,9 @@ export function assertProjectRoomInsertProjectCommand(
export function assertProjectRoomDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectRoomDeleteProjectCommand {
): asserts command is
| ProjectRoomDeleteProjectCommand
| LegacyProjectRoomDeleteProjectCommand {
assertProjectRoomStructureProjectCommand(
command,
projectRoomDeleteCommandType
@ -419,15 +461,26 @@ export function assertProjectFloorUpdateProjectCommand(
export function assertProjectRoomUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectRoomUpdateProjectCommand {
): asserts command is
| ProjectRoomUpdateProjectCommand
| LegacyProjectRoomUpdateProjectCommand {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== projectRoomUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported project-room update command.");
}
if (command.schemaVersion === legacyProjectRoomCommandSchemaVersion) {
assertMatchingRoomUpdate(
upgradeLegacyRoomSnapshot(command.payload.expected),
upgradeLegacyRoomSnapshot(command.payload.target)
);
return;
}
if (command.schemaVersion !== projectRoomCommandSchemaVersion) {
throw new Error("Unsupported project-room update command.");
}
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
}
@ -480,6 +533,51 @@ export function upgradeProjectFloorCommand(
}
}
/** Upgrades persisted version-1 room commands with an empty area. */
export function upgradeProjectRoomCommand(
command:
| ProjectRoomInsertProjectCommand
| LegacyProjectRoomInsertProjectCommand
): ProjectRoomInsertProjectCommand;
export function upgradeProjectRoomCommand(
command:
| ProjectRoomDeleteProjectCommand
| LegacyProjectRoomDeleteProjectCommand
): ProjectRoomDeleteProjectCommand;
export function upgradeProjectRoomCommand(
command:
| ProjectRoomUpdateProjectCommand
| LegacyProjectRoomUpdateProjectCommand
): ProjectRoomUpdateProjectCommand;
export function upgradeProjectRoomCommand(
command:
| ProjectRoomInsertProjectCommand
| ProjectRoomDeleteProjectCommand
| ProjectRoomUpdateProjectCommand
| LegacyProjectRoomInsertProjectCommand
| LegacyProjectRoomDeleteProjectCommand
| LegacyProjectRoomUpdateProjectCommand
) {
if (command.schemaVersion === projectRoomCommandSchemaVersion) {
return command;
}
switch (command.type) {
case projectRoomInsertCommandType:
return createProjectRoomInsertProjectCommand(
upgradeLegacyRoomSnapshot(command.payload.room)
);
case projectRoomDeleteCommandType:
return createProjectRoomDeleteProjectCommand(
upgradeLegacyRoomSnapshot(command.payload.room)
);
case projectRoomUpdateCommandType:
return createProjectRoomUpdateProjectCommand(
upgradeLegacyRoomSnapshot(command.payload.expected),
upgradeLegacyRoomSnapshot(command.payload.target)
);
}
}
function assertMatchingBuildingUpdate(expected: unknown, target: unknown) {
assertProjectBuildingSnapshot(expected);
assertProjectBuildingSnapshot(target);
@ -568,6 +666,29 @@ function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
export function assertProjectRoomSnapshot(
room: unknown
): asserts room is ProjectRoomSnapshot {
if (!isPlainObject(room) || Object.keys(room).length !== 6) {
throw new Error("Project-room snapshot is invalid.");
}
assertNormalizedNonEmptyString(room.id, "room.id");
assertNormalizedNonEmptyString(room.projectId, "room.projectId");
if (room.floorId !== null) {
assertNormalizedNonEmptyString(room.floorId, "room.floorId");
}
assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber");
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
if (
room.areaM2 !== null &&
(typeof room.areaM2 !== "number" ||
!Number.isFinite(room.areaM2) ||
room.areaM2 < 0)
) {
throw new Error("room.areaM2 must be null or a non-negative finite number.");
}
}
function assertLegacyProjectRoomSnapshot(
room: unknown
): asserts room is LegacyProjectRoomSnapshot {
if (!isPlainObject(room) || Object.keys(room).length !== 5) {
throw new Error("Project-room snapshot is invalid.");
}
@ -580,6 +701,11 @@ export function assertProjectRoomSnapshot(
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
}
function upgradeLegacyRoomSnapshot(room: unknown): ProjectRoomSnapshot {
assertLegacyProjectRoomSnapshot(room);
return { ...room, areaM2: null };
}
function assertProjectBuildingStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
@ -627,13 +753,19 @@ function assertProjectRoomStructureProjectCommand(
| typeof projectRoomDeleteCommandType
) {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported project-room structure command.");
}
if (command.schemaVersion === legacyProjectRoomCommandSchemaVersion) {
assertLegacyProjectRoomSnapshot(command.payload.room);
return;
}
if (command.schemaVersion !== projectRoomCommandSchemaVersion) {
throw new Error("Unsupported project-room structure command.");
}
assertProjectRoomSnapshot(command.payload.room);
}