Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,19 @@
{
"name": "@toolctrl/view-models",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "pnpm run typecheck",
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { buildSelectTenantVM, toTenantCard, type SelectTenantInput } from "./select-tenant.vm.js";
function tenant(overrides: Partial<SelectTenantInput> & { id: string; name: string; slug: string }): SelectTenantInput {
return { ...overrides };
}
describe("buildSelectTenantVM", () => {
it("returns an empty state when the user has no tenants", () => {
expect(buildSelectTenantVM({ tenants: [] })).toEqual({ status: "empty" });
});
it("maps tenants to cards, derives initials, and assigns the role tag + tone", () => {
const vm = buildSelectTenantVM({
tenants: [tenant({ id: "t1", name: "Berger Bad", slug: "berger-bad", role: "owner", subtitle: "t.berger@berger-bad.de" })],
});
expect(vm.status).toBe("ready");
const card = vm.data!.tenants[0];
expect(card.avatarInitials).toBe("BB");
expect(card.role).toBe("owner");
expect(card.roleLabelKey).toBe("account.selectTenant.role.owner");
expect(card.roleTone).toBe("red-subtle");
expect(card.subtitle).toBe("t.berger@berger-bad.de");
expect(vm.data!.totalCount).toBe(1);
});
it("uses the image and a neutral avatar color when a logo/icon is present", () => {
const card = toTenantCard({ id: "t1", name: "Acme", slug: "acme", image: "https://x/y.png" });
expect(card.avatarImage).toBe("https://x/y.png");
expect(card.avatarColor).toBe("neutral");
});
it("derives a deterministic avatar color from the id when no image is present", () => {
const a = toTenantCard({ id: "t1", name: "Acme", slug: "acme" });
const b = toTenantCard({ id: "t1", name: "Acme", slug: "acme" });
expect(a.avatarColor).toBe(b.avatarColor);
expect(a.avatarColor).not.toBe("neutral");
expect(a.roleLabelKey).toBeUndefined();
expect(a.roleTone).toBeUndefined();
});
it("flags and sorts the current/last-used tenant to the front", () => {
const vm = buildSelectTenantVM({
tenants: [
tenant({ id: "t1", name: "Zeta", slug: "zeta" }),
tenant({ id: "t2", name: "Alpha", slug: "alpha" }),
tenant({ id: "t3", name: "Mu", slug: "mu" }),
],
currentTenantId: "t3",
});
expect(vm.data!.tenants[0].id).toBe("t3");
expect(vm.data!.tenants[0].isCurrent).toBe(true);
// restliche alphabetisch
expect(vm.data!.tenants.slice(1).map((c) => c.name)).toEqual(["Alpha", "Zeta"]);
});
});
@@ -0,0 +1,118 @@
import type { VMState } from "../index.js";
/**
* Tenant-/Profil-Auswahl View-Model (D-D0a, Flow "Login Tenantauswahl (Admin)", M013-Konvention).
*
* Reine Funktion: ein Loader holt die Tenants des Users (getMyTenants) + dessen Rolle je Tenant
* (getTenantUserType) und baut dieses VM; die UI (Davids Subframe-Karten) rendert nur. Kein React,
* kein Prisma, keine App-Imports (DoD A.3). Rollen-Labels werden als i18n-Key ausgegeben (kein
* vorformatierter Klartext, DoD i18n); die Tag-Tone-Zuordnung ist deterministisch.
*/
export type TenantRole = "owner" | "admin" | "member";
/** Tag_Label-Tone-Literale (Teilmenge), die die UI direkt durchreichen kann. */
export type RoleTone = "red-subtle" | "blue-subtle" | "green-subtle" | "neutral-subtle";
export type AvatarColor = "neutral" | "blue" | "green" | "red" | "yellow" | "purple";
export interface SelectTenantInput {
readonly id: string;
readonly name: string;
readonly slug: string;
/** Bild-Quelle (Tenant-Icon/Logo); fehlt -> Initialen-Avatar. */
readonly image?: string | null;
/** Rolle des aktuellen Users in diesem Tenant; fehlt -> kein Tag. */
readonly role?: TenantRole | null;
/** Optionaler Untertitel als DATEN (z.B. User-Email). */
readonly subtitle?: string | null;
}
export interface BuildSelectTenantInput {
readonly tenants: readonly SelectTenantInput[];
/** Tenant, der als "zuletzt genutzt" markiert wird (User.defaultTenantId). */
readonly currentTenantId?: string | null;
}
export interface TenantCardData {
readonly id: string;
readonly name: string;
readonly slug: string;
/** Bild-URL, wenn vorhanden; sonst undefined -> Initialen nutzen. */
readonly avatarImage?: string;
/** Bis zu zwei Großbuchstaben aus dem Tenant-Namen. */
readonly avatarInitials: string;
readonly avatarColor: AvatarColor;
readonly role?: TenantRole;
/** i18n-Key des Rollen-Labels (z.B. account.selectTenant.role.owner). */
readonly roleLabelKey?: string;
readonly roleTone?: RoleTone;
/** "zuletzt genutzt"-Markierung. */
readonly isCurrent: boolean;
readonly subtitle?: string;
}
export interface SelectTenantData {
readonly tenants: readonly TenantCardData[];
readonly totalCount: number;
}
const ROLE_TONE: Record<TenantRole, RoleTone> = {
owner: "red-subtle",
admin: "blue-subtle",
member: "green-subtle",
};
const AVATAR_PALETTE: readonly AvatarColor[] = ["blue", "green", "red", "yellow", "purple"];
/** Deterministische, stabile Farbe aus einem String (kein Math.random — testbar). */
function colorFor(seed: string): AvatarColor {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
}
function initialsFor(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "?";
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
export function toTenantCard(input: SelectTenantInput, currentTenantId?: string | null): TenantCardData {
const role = input.role ?? undefined;
return {
id: input.id,
name: input.name,
slug: input.slug,
avatarImage: input.image ?? undefined,
avatarInitials: initialsFor(input.name),
avatarColor: input.image ? "neutral" : colorFor(input.id),
role,
roleLabelKey: role ? `account.selectTenant.role.${role}` : undefined,
roleTone: role ? ROLE_TONE[role] : undefined,
isCurrent: !!currentTenantId && input.id === currentTenantId,
subtitle: input.subtitle ?? undefined,
};
}
/**
* Baut das Auswahl-VM. Leere Liste -> "empty" (Empty-State in der UI, DoD 23).
* Der aktuelle/zuletzt genutzte Tenant wird nach vorne sortiert.
*/
export function buildSelectTenantVM(input: BuildSelectTenantInput): VMState<SelectTenantData> {
if (!input.tenants || input.tenants.length === 0) {
return { status: "empty" };
}
const cards = input.tenants.map((t) => toTenantCard(t, input.currentTenantId));
const sorted = [...cards].sort((a, b) => {
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
return a.name.localeCompare(b.name);
});
return {
status: "ready",
data: { tenants: sorted, totalCount: sorted.length },
};
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { buildSessionListVM, type SessionDeviceInput } from "./session-list.vm.js";
const NOW = Date.UTC(2026, 5, 18, 12, 0, 0);
function session(overrides: Partial<SessionDeviceInput> & { id: string }): SessionDeviceInput {
return {
lastSeenAt: new Date(NOW),
...overrides,
};
}
describe("buildSessionListVM", () => {
it("returns an empty state when there are no sessions", () => {
expect(buildSessionListVM({ sessions: [], currentSessionId: "s1", nowMs: NOW })).toEqual({ status: "empty" });
});
it("flags the current device, forbids revoking it, and counts the others", () => {
const vm = buildSessionListVM({
sessions: [
session({ id: "s1", userAgent: "Firefox", approxLocation: "Germany" }),
session({ id: "s2", deviceLabel: "iPhone", trustedAt: new Date(NOW) }),
],
currentSessionId: "s1",
nowMs: NOW,
});
expect(vm.status).toBe("ready");
if (vm.status !== "ready") return;
const current = vm.data.devices.find((d) => d.id === "s1")!;
const other = vm.data.devices.find((d) => d.id === "s2")!;
expect(current.isCurrent).toBe(true);
expect(current.canRevoke).toBe(false);
expect(current.deviceLabel).toBe("Firefox");
expect(other.isCurrent).toBe(false);
expect(other.canRevoke).toBe(true);
expect(other.trusted).toBe(true);
expect(vm.data.otherActiveCount).toBe(1);
expect(vm.data.canRevokeOthers).toBe(true);
});
it("orders the current device first", () => {
const vm = buildSessionListVM({
sessions: [session({ id: "s2" }), session({ id: "s1" })],
currentSessionId: "s1",
nowMs: NOW,
});
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.devices[0].id).toBe("s1");
});
it("falls back to an i18n key when no device label or user agent is present", () => {
const vm = buildSessionListVM({ sessions: [session({ id: "s1" })], currentSessionId: null, nowMs: NOW });
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.devices[0].deviceLabel).toBeUndefined();
expect(vm.data.devices[0].deviceLabelFallbackKey).toBe("settings.security.sessions.unknownDevice");
});
it.each([
[30 * 1000, "justNow", undefined],
[5 * 60 * 1000, "minutes", 5],
[3 * 60 * 60 * 1000, "hours", 3],
[2 * 24 * 60 * 60 * 1000, "days", 2],
])("buckets last-seen %ims ago as %s", (ago, bucket, count) => {
const vm = buildSessionListVM({
sessions: [session({ id: "s1", lastSeenAt: new Date(NOW - (ago as number)) })],
currentSessionId: "s1",
nowMs: NOW,
});
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.devices[0].lastSeenBucket).toBe(bucket);
expect(vm.data.devices[0].lastSeenCount).toBe(count);
});
it("has no revocable others when only the current device is active", () => {
const vm = buildSessionListVM({ sessions: [session({ id: "s1" })], currentSessionId: "s1", nowMs: NOW });
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.otherActiveCount).toBe(0);
expect(vm.data.canRevokeOthers).toBe(false);
});
});
@@ -0,0 +1,115 @@
import type { VMState } from "../index.js";
/**
* Session-Geraeteliste View-Model (R-P2a S6, M013-Konvention).
*
* Reine Funktion: ein Loader holt die aktiven UserSession-Zeilen (sessionStore.listActiveUserSessions)
* und baut dieses VM; die UI (Settings > Sicherheit, Davids D-P2) rendert nur. Kein React, kein Prisma,
* keine App-Imports (DoD A.3). Last-Seen wird als i18n-Bucket + Count ausgegeben (kein vorformatierter
* Klartext, DoD i18n); `nowMs` ist Eingabe, damit die Funktion deterministisch testbar bleibt.
*/
export interface SessionDeviceInput {
readonly id: string;
readonly lastSeenAt: string | Date;
// AD-22: keine Klartext-IP — die Herkunft kommt als Land (approxLocation).
readonly userAgent?: string | null;
readonly deviceLabel?: string | null;
readonly approxLocation?: string | null;
readonly trustedAt?: string | Date | null;
}
export type LastSeenBucket = "justNow" | "minutes" | "hours" | "days";
export interface SessionDeviceData {
readonly id: string;
readonly isCurrent: boolean;
readonly trusted: boolean;
/** Roh-Label (deviceLabel oder userAgent) als DATEN; i18n-Fallback, wenn beides fehlt. */
readonly deviceLabel?: string;
readonly deviceLabelFallbackKey?: string;
readonly approxLocation?: string;
readonly lastSeenIso: string;
readonly lastSeenBucket: LastSeenBucket;
/** Zaehlwert fuer minutes/hours/days (fuer "vor N Minuten"); bei justNow undefined. */
readonly lastSeenCount?: number;
/** Aktuelle Session beendet man ueber normales Abmelden, nicht ueber die Geraeteliste. */
readonly canRevoke: boolean;
}
export interface SessionListData {
readonly devices: readonly SessionDeviceData[];
readonly currentDeviceId: string | null;
/** Anzahl anderer aktiver Geraete (fuer "ueberall abmelden (N)"). */
readonly otherActiveCount: number;
readonly canRevokeOthers: boolean;
}
export type SessionListVM = VMState<SessionListData>;
export interface BuildSessionListVMInput {
readonly sessions: readonly SessionDeviceInput[];
readonly currentSessionId: string | null;
readonly nowMs: number;
}
const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
function toMs(value: string | Date): number {
return value instanceof Date ? value.getTime() : new Date(value).getTime();
}
function relativeLastSeen(lastSeenMs: number, nowMs: number): { bucket: LastSeenBucket; count?: number } {
const diff = Math.max(0, nowMs - lastSeenMs);
if (diff < MINUTE_MS) {
return { bucket: "justNow" };
}
if (diff < HOUR_MS) {
return { bucket: "minutes", count: Math.floor(diff / MINUTE_MS) };
}
if (diff < DAY_MS) {
return { bucket: "hours", count: Math.floor(diff / HOUR_MS) };
}
return { bucket: "days", count: Math.floor(diff / DAY_MS) };
}
function toDevice(session: SessionDeviceInput, currentSessionId: string | null, nowMs: number): SessionDeviceData {
const isCurrent = session.id === currentSessionId;
const rawLabel = session.deviceLabel?.trim() || session.userAgent?.trim() || undefined;
const lastSeenMs = toMs(session.lastSeenAt);
const relative = relativeLastSeen(lastSeenMs, nowMs);
return {
id: session.id,
isCurrent,
trusted: Boolean(session.trustedAt),
deviceLabel: rawLabel,
deviceLabelFallbackKey: rawLabel ? undefined : "settings.security.sessions.unknownDevice",
approxLocation: session.approxLocation?.trim() || undefined,
lastSeenIso: new Date(lastSeenMs).toISOString(),
lastSeenBucket: relative.bucket,
lastSeenCount: relative.count,
canRevoke: !isCurrent,
};
}
export function buildSessionListVM(input: BuildSessionListVMInput): SessionListVM {
if (input.sessions.length === 0) {
return { status: "empty" };
}
const devices = input.sessions
.map((session) => toDevice(session, input.currentSessionId, input.nowMs))
// Aktuelles Geraet zuerst, der Rest nach zuletzt gesehen (Eingabe ist i.d.R. schon desc sortiert).
.sort((a, b) => (a.isCurrent === b.isCurrent ? 0 : a.isCurrent ? -1 : 1));
const otherActiveCount = devices.filter((device) => !device.isCurrent).length;
return {
status: "ready",
data: {
devices,
currentDeviceId: input.currentSessionId,
otherActiveCount,
canRevokeOthers: otherActiveCount > 0,
},
};
}
@@ -0,0 +1,121 @@
import type { VMState } from "../index.js";
/**
* Files browser view-model (AK-P4a §9). Pure function: the loader builds it once
* server-side, the UI only renders. No React, no Prisma, no app imports
* (DoD A.3). All texts are i18n keys.
*
* URLs (`thumbnailUrl`, `detailHref`) ALWAYS point at the auth-proxy route
* (AD-23) or internal routes, NEVER at a raw S3 URL — the loader builds them and
* passes them in here so the VM stays routing-agnostic + pure.
*/
export type FilePreviewStatus = "pending" | "generating" | "ready" | "failed" | "not_supported";
export type FileScanStatus = "pending" | "clean" | "infected" | "skipped";
export type FileViewMode = "table" | "grid";
export interface FileBrowserRow {
readonly id: string;
readonly displayName: string;
readonly mimeType: string;
readonly sizeBytes: number;
readonly createdAtIso: string;
readonly tags: readonly string[];
readonly source: string;
readonly previewStatus: FilePreviewStatus;
readonly scanStatus: FileScanStatus;
/** Proxy-route URL of the thumbnail (or null while not yet `ready`). */
readonly thumbnailUrl: string | null;
/** Internal detail route (e.g. `/acme/files/<id>`). */
readonly detailHref: string;
/** Label of the linked target entity (i18n key) or null. */
readonly linkedEntityLabelKey: string | null;
}
export interface FileBrowserFilterState {
readonly entityType: string | null;
readonly mimeGroup: string | null;
readonly tags: readonly string[];
readonly assignedToUserId: string | null;
readonly includeArchived: boolean;
}
export interface FileBrowserData {
readonly viewMode: FileViewMode;
readonly rows: readonly FileBrowserRow[];
readonly filter: FileBrowserFilterState;
readonly canWrite: boolean;
readonly canDelete: boolean;
/** Cursor of the next page (null = last page). */
readonly nextCursor: string | null;
readonly emptyHeadlineKey: string;
readonly emptyBodyKey: string;
}
export type FileBrowserVM = VMState<FileBrowserData>;
/**
* Normalized prop-model the UI implementation renders (M015 Subframe-adapter
* contract). The ui-app adapter maps {@link FileBrowserData} -> this model once,
* then the ui-subframe / ui-mock renderers stay dumb (hot-swap drill, M017).
*/
export interface FileBrowserModelRow {
readonly id: string;
readonly displayName: string;
readonly mimeType: string;
readonly sizeBytes: number;
readonly detailHref: string;
readonly thumbnailUrl: string | null;
}
export interface FileBrowserModel {
readonly titleKey: string;
readonly emptyHeadlineKey: string;
readonly emptyBodyKey: string;
readonly canWrite: boolean;
readonly rows: readonly FileBrowserModelRow[];
readonly rowCount: number;
}
export interface BuildFileBrowserVMInput {
readonly viewMode?: FileViewMode;
readonly rows: readonly FileBrowserRow[];
readonly filter: FileBrowserFilterState;
readonly canWrite: boolean;
readonly canDelete: boolean;
readonly nextCursor?: string | null;
}
export function buildFileBrowserVM(input: BuildFileBrowserVMInput): FileBrowserVM {
const viewMode: FileViewMode = input.viewMode ?? "table";
if (input.rows.length === 0) {
// Empty stays a `ready` state with a plain-text empty state (DoD 23) so the
// filter/toggle bar and upload CTA remain visible.
return {
status: "ready",
data: {
viewMode,
rows: [],
filter: input.filter,
canWrite: input.canWrite,
canDelete: input.canDelete,
nextCursor: null,
emptyHeadlineKey: "files.browser.empty.headline",
emptyBodyKey: "files.browser.empty.body",
},
};
}
return {
status: "ready",
data: {
viewMode,
rows: input.rows,
filter: input.filter,
canWrite: input.canWrite,
canDelete: input.canDelete,
nextCursor: input.nextCursor ?? null,
emptyHeadlineKey: "files.browser.empty.headline",
emptyBodyKey: "files.browser.empty.body",
},
};
}
@@ -0,0 +1,106 @@
import type { VMState } from "../index.js";
import type { FilePreviewStatus, FileScanStatus } from "./file-browser.vm.js";
/**
* File detail view-model (AK-P4a §9). Pure function, i18n keys only.
* Preview/download URLs point at the auth-proxy route (AD-23).
*/
export interface FileLinkedEntity {
readonly entityType: string;
readonly entityId: string;
readonly linkRole: string | null;
/** Internal route to the target entity (loader-built) or null. */
readonly href: string | null;
}
export interface FileDetailData {
readonly id: string;
readonly displayName: string;
readonly mimeType: string;
readonly sizeBytes: number;
readonly createdAtIso: string;
readonly tags: readonly string[];
readonly notes: string | null;
readonly source: string;
readonly previewStatus: FilePreviewStatus;
readonly scanStatus: FileScanStatus;
/** Proxy URL of the preview (or null). */
readonly previewUrl: string | null;
/** Proxy URL of the original for download. */
readonly downloadUrl: string;
readonly assignedToUserId: string | null;
readonly linkedEntities: readonly FileLinkedEntity[];
/** Plain-text hint key when previewStatus=not_supported/failed. */
readonly previewFallbackKey: string | null;
/** Plain-text key when scanStatus=infected (download blocked) or pending. */
readonly scanNoticeKey: string | null;
readonly canWrite: boolean;
readonly canDelete: boolean;
readonly canAdmin: boolean;
/** Regenerating the preview only makes sense when it failed. */
readonly canRegeneratePreview: boolean;
}
export type FileDetailVM = VMState<FileDetailData>;
export interface BuildFileDetailVMInput {
readonly id: string;
readonly displayName: string;
readonly mimeType: string;
readonly sizeBytes: number;
readonly createdAtIso: string;
readonly tags: readonly string[];
readonly notes: string | null;
readonly source: string;
readonly previewStatus: FilePreviewStatus;
readonly scanStatus: FileScanStatus;
readonly previewUrl: string | null;
readonly downloadUrl: string;
readonly assignedToUserId: string | null;
readonly linkedEntities: readonly FileLinkedEntity[];
readonly canWrite: boolean;
readonly canDelete: boolean;
readonly canAdmin: boolean;
}
function previewFallbackKey(status: FilePreviewStatus): string | null {
if (status === "not_supported") return "files.detail.preview.notSupported";
if (status === "failed") return "files.detail.preview.failed";
if (status === "pending" || status === "generating") return "files.detail.preview.pending";
return null;
}
function scanNoticeKey(status: FileScanStatus): string | null {
if (status === "infected") return "files.detail.scan.infected";
if (status === "pending") return "files.detail.scan.pending";
return null;
}
export function buildFileDetailVM(input: BuildFileDetailVMInput): FileDetailVM {
return {
status: "ready",
data: {
id: input.id,
displayName: input.displayName,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
createdAtIso: input.createdAtIso,
tags: input.tags,
notes: input.notes,
source: input.source,
previewStatus: input.previewStatus,
scanStatus: input.scanStatus,
// An infected file never exposes a preview URL (fail-closed).
previewUrl: input.scanStatus === "infected" ? null : input.previewUrl,
downloadUrl: input.downloadUrl,
assignedToUserId: input.assignedToUserId,
linkedEntities: input.linkedEntities,
previewFallbackKey: previewFallbackKey(input.previewStatus),
scanNoticeKey: scanNoticeKey(input.scanStatus),
canWrite: input.canWrite,
canDelete: input.canDelete,
canAdmin: input.canAdmin,
canRegeneratePreview: input.canWrite && input.previewStatus === "failed",
},
};
}
@@ -0,0 +1,63 @@
import type { VMState } from "../index.js";
/**
* Upload view view-model (AK-P4a §9). Pure function, i18n keys only.
* Describes the multi-file queue with per-file progress + plain-text errors
* (HEIC hint, too large, MIME blocked, network abort).
*/
export type FileUploadItemStatus = "queued" | "uploading" | "processing" | "done" | "error";
export interface FileUploadQueueItem {
readonly clientId: string;
readonly filename: string;
readonly sizeBytes: number;
readonly status: FileUploadItemStatus;
/** 0..100 */
readonly progressPercent: number;
/** i18n key of the error message (only when status=error). */
readonly errorKey: string | null;
/** Plain-text hint key (e.g. HEIC will be converted to JPG). */
readonly noticeKey: string | null;
readonly canRetry: boolean;
}
export interface FileUploadData {
readonly canWrite: boolean;
readonly chunkSizeBytes: number;
readonly maxFileSizeBytes: number;
readonly maxParallelUploads: number;
readonly items: readonly FileUploadQueueItem[];
readonly dropzoneHeadlineKey: string;
readonly dropzoneBodyKey: string;
readonly heicNoticeKey: string;
}
export type FileUploadVM = VMState<FileUploadData>;
export const FILE_UPLOAD_CHUNK_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
export const FILE_UPLOAD_MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024; // 500 MB
export const FILE_UPLOAD_MAX_PARALLEL = 3;
export interface BuildFileUploadVMInput {
readonly canWrite: boolean;
readonly items?: readonly FileUploadQueueItem[];
readonly chunkSizeBytes?: number;
readonly maxFileSizeBytes?: number;
readonly maxParallelUploads?: number;
}
export function buildFileUploadVM(input: BuildFileUploadVMInput): FileUploadVM {
return {
status: "ready",
data: {
canWrite: input.canWrite,
chunkSizeBytes: input.chunkSizeBytes ?? FILE_UPLOAD_CHUNK_SIZE_BYTES,
maxFileSizeBytes: input.maxFileSizeBytes ?? FILE_UPLOAD_MAX_FILE_SIZE_BYTES,
maxParallelUploads: input.maxParallelUploads ?? FILE_UPLOAD_MAX_PARALLEL,
items: input.items ?? [],
dropzoneHeadlineKey: "files.upload.dropzone.headline",
dropzoneBodyKey: "files.upload.dropzone.body",
heicNoticeKey: "files.upload.notice.heic",
},
};
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
buildFileBrowserVM,
buildFileDetailVM,
buildFileUploadVM,
FILE_UPLOAD_CHUNK_SIZE_BYTES,
FILE_UPLOAD_MAX_FILE_SIZE_BYTES,
type BuildFileDetailVMInput,
type FileBrowserFilterState,
type FileBrowserRow,
} from "../index.js";
const I18N_KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
const filter: FileBrowserFilterState = {
entityType: null,
mimeGroup: null,
tags: [],
assignedToUserId: null,
includeArchived: false,
};
describe("buildFileBrowserVM", () => {
it("returns a ready empty-state with i18n keys when there are no rows", () => {
const vm = buildFileBrowserVM({ rows: [], filter, canWrite: true, canDelete: false });
expect(vm.status).toBe("ready");
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.rows).toHaveLength(0);
expect(vm.data.viewMode).toBe("table");
expect(vm.data.emptyHeadlineKey).toMatch(I18N_KEY);
expect(vm.data.emptyBodyKey).toMatch(I18N_KEY);
});
it("passes rows through and honours the view mode + cursor", () => {
const row: FileBrowserRow = {
id: "f1",
displayName: "foto.heic",
mimeType: "image/heic",
sizeBytes: 1234,
createdAtIso: "2026-06-19T08:00:00.000Z",
tags: ["baustelle"],
source: "manual_upload",
previewStatus: "ready",
scanStatus: "clean",
thumbnailUrl: "/acme/media/f1/thumb",
detailHref: "/acme/files/f1",
linkedEntityLabelKey: null,
};
const vm = buildFileBrowserVM({ viewMode: "grid", rows: [row], filter, canWrite: true, canDelete: true, nextCursor: "cur2" });
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.viewMode).toBe("grid");
expect(vm.data.rows[0]?.thumbnailUrl).toBe("/acme/media/f1/thumb");
expect(vm.data.nextCursor).toBe("cur2");
// Proxy/intern, nie rohe S3-URL.
expect(vm.data.rows[0]?.thumbnailUrl?.startsWith("http")).toBe(false);
});
});
describe("buildFileDetailVM", () => {
const base: BuildFileDetailVMInput = {
id: "f1",
displayName: "vertrag.pdf",
mimeType: "application/pdf",
sizeBytes: 9999,
createdAtIso: "2026-06-19T08:00:00.000Z",
tags: [],
notes: null,
source: "manual_upload",
previewStatus: "ready",
scanStatus: "clean",
previewUrl: "/acme/media/f1/preview",
downloadUrl: "/acme/media/f1/original",
assignedToUserId: null,
linkedEntities: [],
canWrite: true,
canDelete: false,
canAdmin: false,
};
it("withholds the preview URL when the file is infected (fail-closed)", () => {
const vm = buildFileDetailVM({ ...base, scanStatus: "infected", previewUrl: "/acme/media/f1/preview" });
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.previewUrl).toBeNull();
expect(vm.data.scanNoticeKey).toMatch(I18N_KEY);
});
it("offers regenerate only for writers on a failed preview", () => {
expect(buildFileDetailVM({ ...base, previewStatus: "failed" }).status).toBe("ready");
const failedWriter = buildFileDetailVM({ ...base, previewStatus: "failed", canWrite: true });
const failedReader = buildFileDetailVM({ ...base, previewStatus: "failed", canWrite: false });
if (failedWriter.status !== "ready" || failedReader.status !== "ready") throw new Error("expected ready");
expect(failedWriter.data.canRegeneratePreview).toBe(true);
expect(failedReader.data.canRegeneratePreview).toBe(false);
expect(failedWriter.data.previewFallbackKey).toMatch(I18N_KEY);
});
it("emits a not-supported fallback key", () => {
const vm = buildFileDetailVM({ ...base, previewStatus: "not_supported", previewUrl: null });
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.previewFallbackKey).toBe("files.detail.preview.notSupported");
});
});
describe("buildFileUploadVM", () => {
it("exposes the chunk/size/parallel defaults and i18n keys", () => {
const vm = buildFileUploadVM({ canWrite: true });
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.chunkSizeBytes).toBe(FILE_UPLOAD_CHUNK_SIZE_BYTES);
expect(vm.data.maxFileSizeBytes).toBe(FILE_UPLOAD_MAX_FILE_SIZE_BYTES);
expect(vm.data.maxParallelUploads).toBe(3);
expect(vm.data.heicNoticeKey).toMatch(I18N_KEY);
expect(vm.data.dropzoneHeadlineKey).toMatch(I18N_KEY);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, test } from "vitest";
import { assertI18nLabel, empty, ready, type ActionCallbacks, type SidebarRegistryItem, type VMState } from "./index.js";
describe("@toolctrl/view-models", () => {
test("exports serializable VM state helpers", () => {
expect(ready({ id: "1" })).toEqual({ status: "ready", data: { id: "1" } } satisfies VMState<{ id: string }>);
expect(empty()).toEqual({ status: "empty" });
});
test("keeps action callbacks generic and UI-free", async () => {
const calls: string[] = [];
const callbacks: ActionCallbacks<{ id: string }> = {
onUpdate: async (item) => {
calls.push(item.id);
},
};
await callbacks.onUpdate?.({ id: "item_1" });
expect(calls).toEqual(["item_1"]);
});
test("requires i18n-key labels for shell registries", () => {
const item: SidebarRegistryItem = { id: "home", href: "/", labelKey: "shell.nav.home", permission: "resource.view" };
expect(assertI18nLabel(item)).toBe(item);
expect(() => assertI18nLabel({ labelKey: "Home" })).toThrow(/i18n keys/);
});
});
@@ -0,0 +1,110 @@
export type VMStatus = "idle" | "loading" | "ready" | "empty" | "error";
export type VMState<T> =
| { status: "idle"; data?: undefined; error?: undefined }
| { status: "loading"; data?: T; error?: undefined }
| { status: "ready"; data: T; error?: undefined }
| { status: "empty"; data?: undefined; error?: undefined }
| { status: "error"; data?: T; error: { messageKey: string; code?: string } };
export type ActionCallbacks<T> = {
readonly onCreate?: (draft?: Partial<T>) => void | Promise<void>;
readonly onUpdate?: (item: T) => void | Promise<void>;
readonly onDelete?: (item: T) => void | Promise<void>;
readonly onRefresh?: () => void | Promise<void>;
};
export type I18nLabel = {
readonly labelKey: string;
readonly descriptionKey?: string;
};
export type RegistryGate = {
readonly permission?: string;
readonly flag?: string;
};
export type SidebarRegistryItem = I18nLabel &
RegistryGate & {
readonly id: string;
readonly href: string;
readonly icon?: string;
readonly children?: readonly SidebarRegistryItem[];
};
export type BreadcrumbRegistryItem = I18nLabel &
RegistryGate & {
readonly id: string;
readonly href?: string;
};
export type MobileRegistryItem = I18nLabel &
RegistryGate & {
readonly id: string;
readonly href: string;
readonly priority?: number;
};
export type PortalRegistryItem = I18nLabel &
RegistryGate & {
readonly id: string;
readonly slot: string;
readonly order?: number;
};
export type ShellRegistries = {
readonly sidebar?: readonly SidebarRegistryItem[];
readonly breadcrumbs?: readonly BreadcrumbRegistryItem[];
readonly mobile?: readonly MobileRegistryItem[];
readonly portals?: readonly PortalRegistryItem[];
};
// --- Subframe-Adapter contract (M015) ---
/** Swappable UI implementation: renders a serializable component model to HTML. */
export type UiRenderer<Model> = (model: Model) => string;
export type DataTableColumn = I18nLabel & { readonly key: string; readonly align?: "left" | "right" | "center" };
export type DataTableRow = { readonly id: string; readonly cells: Readonly<Record<string, string>> };
/** Domain View-Model a loader builds for a tabular list. */
export type DataTableVM = {
readonly caption?: I18nLabel;
readonly columns: readonly DataTableColumn[];
readonly rows: readonly DataTableRow[];
readonly emptyLabelKey?: string;
};
/** Normalized component prop-model the UI implementation renders. */
export type DataTableModel = {
readonly captionKey?: string;
readonly columns: readonly { readonly key: string; readonly labelKey: string; readonly align: "left" | "right" | "center" }[];
readonly rows: readonly DataTableRow[];
readonly emptyLabelKey: string;
readonly rowCount: number;
};
export function ready<T>(data: T): VMState<T> {
return { status: "ready", data };
}
export function empty<T>(): VMState<T> {
return { status: "empty" };
}
export function assertI18nLabel(label: I18nLabel): I18nLabel {
if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(label.labelKey)) {
throw new Error(`Registry labels must use i18n keys: ${label.labelKey}`);
}
if (label.descriptionKey && !/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(label.descriptionKey)) {
throw new Error(`Registry descriptions must use i18n keys: ${label.descriptionKey}`);
}
return label;
}
// Domain View-Models (M013-Konvention: src/<domain>/*.vm.ts)
export * from "./walking-skeleton/walking-skeleton.vm.js";
export * from "./auth/session-list.vm.js";
export * from "./auth/select-tenant.vm.js";
export * from "./files/file-browser.vm.js";
export * from "./files/file-detail.vm.js";
export * from "./files/file-upload.vm.js";
export * from "./tasks/task-list.vm.js";
export * from "./tasks/task-detail.vm.js";
@@ -0,0 +1,118 @@
import type { VMState } from "../index.js";
import {
taskEntityLabelKey,
taskPriorityKey,
taskSourceKey,
taskStatusKey,
type TaskAssignee,
type TaskLinkedEntity,
type TaskPriority,
type TaskSource,
type TaskStatus,
} from "./task-list.vm.js";
/**
* Task detail view-model (AK-P4b §6). Pure function: the loader builds it once
* server-side. Carries the display fields plus the edit-form picker state
* (assignee person↔group, entity link, status/priority). Markdown description is
* passed raw; the UI renders it. All static texts are i18n keys; resolved names
* are data.
*/
export interface TaskPickerOption {
readonly id: string;
/** Resolved display label (data). */
readonly label: string;
}
export interface TaskWatcherView {
readonly userId: string;
readonly label: string;
}
export interface TaskDetailData {
readonly id: string;
readonly title: string;
/** Raw markdown description; UI renders it (null when unset). */
readonly description: string | null;
readonly status: TaskStatus;
readonly statusKey: string;
readonly priority: TaskPriority;
readonly priorityKey: string;
readonly deadlineIso: string | null;
readonly isOverdue: boolean;
readonly assignee: TaskAssignee | null;
readonly linkedEntity: TaskLinkedEntity | null;
readonly source: TaskSource;
readonly sourceKey: string;
readonly sourceHref: string | null;
readonly watchers: readonly TaskWatcherView[];
readonly createdAtIso: string;
readonly completedAtIso: string | null;
// Edit-form picker option lists (assignee person↔group). Empty when the user
// may not write.
readonly assignableUsers: readonly TaskPickerOption[];
readonly assignableGroups: readonly TaskPickerOption[];
readonly canWrite: boolean;
readonly canDelete: boolean;
/** Back link to the task list (breadcrumb / cancel target). */
readonly listHref: string;
}
export type TaskDetailVM = VMState<TaskDetailData>;
export interface TaskDetailSource {
readonly id: string;
readonly title: string;
readonly description: string | null;
readonly status: TaskStatus;
readonly priority: TaskPriority;
readonly deadlineIso: string | null;
readonly isOverdue: boolean;
readonly assignee: TaskAssignee | null;
readonly linkedEntity: TaskLinkedEntity | null;
readonly source: TaskSource;
readonly sourceHref: string | null;
readonly watchers: readonly TaskWatcherView[];
readonly createdAtIso: string;
readonly completedAtIso: string | null;
}
export interface BuildTaskDetailVMInput {
readonly task: TaskDetailSource;
readonly assignableUsers?: readonly TaskPickerOption[];
readonly assignableGroups?: readonly TaskPickerOption[];
readonly canWrite: boolean;
readonly canDelete: boolean;
readonly listHref: string;
}
export function buildTaskDetailVM(input: BuildTaskDetailVMInput): TaskDetailVM {
const { task } = input;
return {
status: "ready",
data: {
id: task.id,
title: task.title,
description: task.description,
status: task.status,
statusKey: taskStatusKey(task.status),
priority: task.priority,
priorityKey: taskPriorityKey(task.priority),
deadlineIso: task.deadlineIso,
isOverdue: task.isOverdue,
assignee: task.assignee,
linkedEntity: task.linkedEntity ? { ...task.linkedEntity, labelKey: taskEntityLabelKey(task.linkedEntity.entityType) } : null,
source: task.source,
sourceKey: taskSourceKey(task.source),
sourceHref: task.sourceHref,
watchers: task.watchers,
createdAtIso: task.createdAtIso,
completedAtIso: task.completedAtIso,
assignableUsers: input.canWrite ? (input.assignableUsers ?? []) : [],
assignableGroups: input.canWrite ? (input.assignableGroups ?? []) : [],
canWrite: input.canWrite,
canDelete: input.canDelete,
listHref: input.listHref,
},
};
}
@@ -0,0 +1,178 @@
import type { VMState } from "../index.js";
/**
* Task list view-model (AK-P4b §6). Pure function: the loader builds it once
* server-side, the UI only renders. No React, no Prisma, no app imports
* (DoD A.3). All static texts are i18n keys; resolved names (assignee, entity
* label) are data passed in by the loader.
*
* This VM backs the REUSABLE task list (DoD 8): the standalone `/tasks` browser
* and the embedded "Tasks tab" in every entity detail view (P12/P14/P16) render
* the same component from this shape. `detailHref`/`createHref`/`sourceHref` are
* always internal routes built by the loader, so the VM stays routing-agnostic.
*/
export type TaskStatus = "open" | "in_progress" | "blocked" | "done" | "cancelled";
export type TaskPriority = "low" | "normal" | "high" | "urgent";
export type TaskSource = "manual" | "chat" | "form_submission" | "automation" | "api";
export type TaskListPreset = "mine" | "created" | "all";
export interface TaskAssignee {
readonly kind: "user" | "group";
/** Resolved display name (data), built by the loader. */
readonly label: string;
}
export interface TaskLinkedEntity {
readonly entityType: string;
readonly entityId: string;
/** i18n key for the entity-type label (e.g. `tasks.entity.contact`). */
readonly labelKey: string;
}
export interface TaskListRow {
readonly id: string;
readonly title: string;
readonly status: TaskStatus;
/** i18n key for the status label (e.g. `tasks.status.open`). */
readonly statusKey: string;
readonly priority: TaskPriority;
/** i18n key for the priority label (e.g. `tasks.priority.high`). */
readonly priorityKey: string;
readonly deadlineIso: string | null;
/** Loader-computed: deadline is in the past and the task is not terminal. */
readonly isOverdue: boolean;
readonly assignee: TaskAssignee | null;
readonly linkedEntity: TaskLinkedEntity | null;
readonly source: TaskSource;
/** i18n key for the source marker (e.g. `tasks.source.chat`). */
readonly sourceKey: string;
/** Internal link back to the task's origin (chat/form/automation) or null. */
readonly sourceHref: string | null;
/** Internal detail route (e.g. `/acme/tasks/<id>`). */
readonly detailHref: string;
}
export interface TaskListFilterState {
readonly preset: TaskListPreset;
readonly status: TaskStatus | null;
readonly priority: TaskPriority | null;
readonly assignedToUserId: string | null;
readonly includeArchived: boolean;
}
/** Optional embedding context: when set, the list is the "Tasks tab" of an
* entity and the create CTA pre-fills the link to that entity. */
export interface TaskListEmbedContext {
readonly linkedEntityType: string;
readonly linkedEntityId: string;
}
export interface TaskListData {
readonly rows: readonly TaskListRow[];
readonly filter: TaskListFilterState;
readonly canWrite: boolean;
readonly canDelete: boolean;
/** Internal route for the "new task" CTA (pre-filled when embedded). */
readonly createHref: string;
readonly embed: TaskListEmbedContext | null;
readonly nextCursor: string | null;
readonly emptyHeadlineKey: string;
readonly emptyBodyKey: string;
}
export type TaskListVM = VMState<TaskListData>;
/**
* Normalized prop-model the UI implementation renders (M015 Subframe-adapter
* contract). The ui-app adapter maps {@link TaskListData} -> this model once,
* then the ui-subframe / ui-mock renderers stay dumb (hot-swap drill, M017).
*/
export interface TaskListModelRow {
readonly id: string;
readonly title: string;
readonly statusKey: string;
readonly priorityKey: string;
readonly deadlineIso: string | null;
readonly isOverdue: boolean;
readonly assigneeLabel: string | null;
readonly detailHref: string;
}
export interface TaskListModel {
readonly titleKey: string;
readonly emptyHeadlineKey: string;
readonly emptyBodyKey: string;
readonly canWrite: boolean;
readonly createHref: string;
readonly rows: readonly TaskListModelRow[];
readonly rowCount: number;
}
export interface BuildTaskListVMInput {
readonly rows: readonly TaskListRow[];
readonly filter: TaskListFilterState;
readonly canWrite: boolean;
readonly canDelete: boolean;
readonly createHref: string;
readonly embed?: TaskListEmbedContext | null;
readonly nextCursor?: string | null;
}
export function buildTaskListVM(input: BuildTaskListVMInput): TaskListVM {
// Empty stays a `ready` state with a plain-text empty state (DoD 23) so the
// filter bar + "new task" CTA remain visible.
return {
status: "ready",
data: {
rows: input.rows,
filter: input.filter,
canWrite: input.canWrite,
canDelete: input.canDelete,
createHref: input.createHref,
embed: input.embed ?? null,
nextCursor: input.rows.length === 0 ? null : (input.nextCursor ?? null),
emptyHeadlineKey: "tasks.list.empty.headline",
emptyBodyKey: "tasks.list.empty.body",
},
};
}
/** Map the domain data to the normalized hot-swap model (used by the ui-app adapter). */
export function toTaskListModel(data: TaskListData): TaskListModel {
return {
titleKey: "tasks.list.title",
emptyHeadlineKey: data.emptyHeadlineKey,
emptyBodyKey: data.emptyBodyKey,
canWrite: data.canWrite,
createHref: data.createHref,
rows: data.rows.map((row) => ({
id: row.id,
title: row.title,
statusKey: row.statusKey,
priorityKey: row.priorityKey,
deadlineIso: row.deadlineIso,
isOverdue: row.isOverdue,
assigneeLabel: row.assignee?.label ?? null,
detailHref: row.detailHref,
})),
rowCount: data.rows.length,
};
}
// --- Pure helpers reused by loaders to keep i18n keys consistent ----------
export function taskStatusKey(status: TaskStatus): string {
return `tasks.status.${status}`;
}
export function taskPriorityKey(priority: TaskPriority): string {
return `tasks.priority.${priority}`;
}
export function taskSourceKey(source: TaskSource): string {
return `tasks.source.${source}`;
}
export function taskEntityLabelKey(entityType: string): string {
return `tasks.entity.${entityType}`;
}
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
buildTaskDetailVM,
buildTaskListVM,
taskEntityLabelKey,
taskPriorityKey,
taskSourceKey,
taskStatusKey,
toTaskListModel,
type BuildTaskDetailVMInput,
type TaskListFilterState,
type TaskListRow,
} from "../index.js";
const I18N_KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
const filter: TaskListFilterState = {
preset: "all",
status: null,
priority: null,
assignedToUserId: null,
includeArchived: false,
};
const row: TaskListRow = {
id: "t1",
title: "Angebot nachfassen",
status: "open",
statusKey: taskStatusKey("open"),
priority: "high",
priorityKey: taskPriorityKey("high"),
deadlineIso: "2026-06-25T08:00:00.000Z",
isOverdue: false,
assignee: { kind: "user", label: "Akeshan" },
linkedEntity: { entityType: "deal", entityId: "d9", labelKey: taskEntityLabelKey("deal") },
source: "manual",
sourceKey: taskSourceKey("manual"),
sourceHref: null,
detailHref: "/acme/tasks/t1",
};
describe("buildTaskListVM", () => {
it("returns a ready empty-state with i18n keys and no cursor when there are no rows", () => {
const vm = buildTaskListVM({ rows: [], filter, canWrite: true, canDelete: false, createHref: "/acme/tasks/new", nextCursor: "x" });
expect(vm.status).toBe("ready");
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.rows).toHaveLength(0);
expect(vm.data.nextCursor).toBeNull();
expect(vm.data.emptyHeadlineKey).toMatch(I18N_KEY);
expect(vm.data.emptyBodyKey).toMatch(I18N_KEY);
expect(vm.data.createHref).toBe("/acme/tasks/new");
});
it("passes rows + embed context + cursor through; hrefs stay internal", () => {
const vm = buildTaskListVM({
rows: [row],
filter: { ...filter, preset: "mine" },
canWrite: true,
canDelete: true,
createHref: "/acme/tasks/new?deal=d9",
embed: { linkedEntityType: "deal", linkedEntityId: "d9" },
nextCursor: "cur2",
});
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.rows[0]?.detailHref.startsWith("http")).toBe(false);
expect(vm.data.embed?.linkedEntityId).toBe("d9");
expect(vm.data.nextCursor).toBe("cur2");
expect(vm.data.filter.preset).toBe("mine");
});
});
describe("toTaskListModel", () => {
it("flattens rows to the hot-swap model (assignee label, status key)", () => {
const vm = buildTaskListVM({ rows: [row], filter, canWrite: false, canDelete: false, createHref: "/acme/tasks/new" });
if (vm.status !== "ready") throw new Error("expected ready");
const model = toTaskListModel(vm.data);
expect(model.rowCount).toBe(1);
expect(model.titleKey).toMatch(I18N_KEY);
expect(model.rows[0]?.assigneeLabel).toBe("Akeshan");
expect(model.rows[0]?.statusKey).toBe("tasks.status.open");
});
});
describe("buildTaskDetailVM", () => {
const base: BuildTaskDetailVMInput = {
task: {
id: "t1",
title: "Angebot nachfassen",
description: "**Markdown** Beschreibung",
status: "open",
priority: "normal",
deadlineIso: null,
isOverdue: false,
assignee: { kind: "group", label: "Vertrieb" },
linkedEntity: { entityType: "contact", entityId: "c1", labelKey: "" },
source: "chat",
sourceHref: "/acme/inbox/msg-7",
watchers: [{ userId: "u2", label: "Raffael" }],
createdAtIso: "2026-06-20T08:00:00.000Z",
completedAtIso: null,
},
assignableUsers: [{ id: "u1", label: "Akeshan" }],
assignableGroups: [{ id: "g1", label: "Vertrieb" }],
canWrite: true,
canDelete: false,
listHref: "/acme/tasks",
};
it("derives i18n keys for status/priority/source/entity and keeps the source link", () => {
const vm = buildTaskDetailVM(base);
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.statusKey).toBe("tasks.status.open");
expect(vm.data.priorityKey).toBe("tasks.priority.normal");
expect(vm.data.sourceKey).toBe("tasks.source.chat");
expect(vm.data.linkedEntity?.labelKey).toBe("tasks.entity.contact");
expect(vm.data.sourceHref).toBe("/acme/inbox/msg-7");
expect(vm.data.watchers[0]?.label).toBe("Raffael");
});
it("hides picker option lists from non-writers", () => {
const vm = buildTaskDetailVM({ ...base, canWrite: false });
if (vm.status !== "ready") throw new Error("expected ready");
expect(vm.data.assignableUsers).toHaveLength(0);
expect(vm.data.assignableGroups).toHaveLength(0);
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { buildWalkingSkeletonVM } from "./walking-skeleton.vm.js";
describe("buildWalkingSkeletonVM", () => {
it("builds a ready VM with active content when the module is active", () => {
const vm = buildWalkingSkeletonVM({ tenantId: "t1", moduleKey: "walking-skeleton", connectorState: "active" });
expect(vm.status).toBe("ready");
expect(vm.data).toMatchObject({
tenantId: "t1",
moduleKey: "walking-skeleton",
connectorState: "active",
headlineKey: "walkingSkeleton.active.headline",
bodyKey: "walkingSkeleton.active.body",
});
expect(vm.data?.ctaKey).toBeUndefined();
});
it("builds a ready VM with locked content + upsell CTA when the module is locked", () => {
const vm = buildWalkingSkeletonVM({ tenantId: "t1", moduleKey: "walking-skeleton", connectorState: "locked" });
expect(vm.status).toBe("ready");
expect(vm.data?.connectorState).toBe("locked");
expect(vm.data?.headlineKey).toBe("walkingSkeleton.locked.headline");
expect(vm.data?.ctaKey).toBe("walkingSkeleton.locked.cta");
});
it("builds an empty VM when the module is hidden", () => {
const vm = buildWalkingSkeletonVM({ tenantId: "t1", moduleKey: "walking-skeleton", connectorState: "hidden" });
expect(vm.status).toBe("empty");
expect(vm.data).toBeUndefined();
});
it("uses only i18n keys, never raw display strings", () => {
const vm = buildWalkingSkeletonVM({ tenantId: "t1", moduleKey: "walking-skeleton", connectorState: "active" });
const keyPattern = /^[a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+$/;
expect(vm.data?.headlineKey).toMatch(keyPattern);
expect(vm.data?.bodyKey).toMatch(keyPattern);
});
});
@@ -0,0 +1,60 @@
import type { VMState } from "../index.js";
/**
* Walking-Skeleton View-Model (R-P1c) — die *.vm.ts-Konvention exemplarisch.
*
* Reine Funktion: der Loader baut sie einmal serverseitig, die UI rendert nur. Kein React,
* kein Prisma, keine App-Imports (DoD A.3). Der Konnektor-Zustand (aus `tenantHasModule()` über
* `resolveConnectorNavState`) bestimmt, welcher VM-Status/Inhalt entsteht.
*/
export type WalkingSkeletonConnectorState = "active" | "locked" | "hidden";
export interface WalkingSkeletonData {
readonly tenantId: string;
readonly moduleKey: string;
readonly connectorState: WalkingSkeletonConnectorState;
/** i18n-Key der Überschrift (kein Klartext — DoD i18n). */
readonly headlineKey: string;
/** i18n-Key des Fließtexts. */
readonly bodyKey: string;
/** i18n-Key des Upsell-CTA (nur im gesperrten Zustand). */
readonly ctaKey?: string;
}
export type WalkingSkeletonVM = VMState<WalkingSkeletonData>;
export interface BuildWalkingSkeletonVMInput {
readonly tenantId: string;
readonly moduleKey: string;
readonly connectorState: WalkingSkeletonConnectorState;
}
export function buildWalkingSkeletonVM(input: BuildWalkingSkeletonVMInput): WalkingSkeletonVM {
if (input.connectorState === "active") {
return {
status: "ready",
data: {
tenantId: input.tenantId,
moduleKey: input.moduleKey,
connectorState: "active",
headlineKey: "walkingSkeleton.active.headline",
bodyKey: "walkingSkeleton.active.body",
},
};
}
if (input.connectorState === "locked") {
return {
status: "ready",
data: {
tenantId: input.tenantId,
moduleKey: input.moduleKey,
connectorState: "locked",
headlineKey: "walkingSkeleton.locked.headline",
bodyKey: "walkingSkeleton.locked.body",
ctaKey: "walkingSkeleton.locked.cta",
},
};
}
// hidden — die Route sollte für diesen Tenant gar nicht erreichbar sein; defensiv leerer Zustand.
return { status: "empty" };
}
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts"]
}