@cosmicdrift/kumiko-bundled-features 0.72.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -84,11 +84,11 @@
84
84
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-dispatcher-live": "0.72.0",
88
- "@cosmicdrift/kumiko-framework": "0.72.0",
89
- "@cosmicdrift/kumiko-headless": "0.72.0",
90
- "@cosmicdrift/kumiko-renderer": "0.72.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.72.0",
87
+ "@cosmicdrift/kumiko-dispatcher-live": "0.74.0",
88
+ "@cosmicdrift/kumiko-framework": "0.74.0",
89
+ "@cosmicdrift/kumiko-headless": "0.74.0",
90
+ "@cosmicdrift/kumiko-renderer": "0.74.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.74.0",
92
92
  "@mollie/api-client": "^4.5.0",
93
93
  "@node-rs/argon2": "^2.0.2",
94
94
  "@types/nodemailer": "^8.0.0",
@@ -108,5 +108,8 @@
108
108
  "src",
109
109
  "README.md",
110
110
  "LICENSE"
111
- ]
111
+ ],
112
+ "devDependencies": {
113
+ "@testing-library/user-event": "^14.6.1"
114
+ }
112
115
  }
@@ -6,7 +6,8 @@ import {
6
6
  PrimitivesProvider,
7
7
  } from "@cosmicdrift/kumiko-renderer";
8
8
  import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
9
- import { fireEvent, render, screen, waitFor } from "@testing-library/react";
9
+ import { render, screen, waitFor } from "@testing-library/react";
10
+ import userEvent from "@testing-library/user-event";
10
11
  import type { ReactNode } from "react";
11
12
  import { CustomFieldsFormSection } from "../custom-fields-form-section";
12
13
  import { defaultTranslations } from "../i18n";
@@ -51,6 +52,7 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
51
52
 
52
53
  describe("CustomFieldsFormSection", () => {
53
54
  test("renders an input per matching fieldDefinition and dispatches set-custom-field on save", async () => {
55
+ const user = userEvent.setup();
54
56
  mockedQueryRows = [
55
57
  {
56
58
  id: "f1",
@@ -94,10 +96,10 @@ describe("CustomFieldsFormSection", () => {
94
96
  expect(document.getElementById("custom-field-rootCause")).toBeNull();
95
97
 
96
98
  // Type in vendor; tier left empty (should be skipped on save).
97
- fireEvent.change(vendorInput, { target: { value: "Hetzner" } });
99
+ await user.type(vendorInput, "Hetzner");
98
100
 
99
101
  const saveBtn = screen.getByTestId("custom-fields-form-save");
100
- fireEvent.click(saveBtn);
102
+ await user.click(saveBtn);
101
103
  // waitFor statt fester Promise.resolve()-Ticks — robust gegen zusätzliche
102
104
  // Microtasks im async handleSave-Loop (z.B. ein neuer dispatch-Wrapper).
103
105
  await waitFor(() => expect(dispatchSpy).toHaveBeenCalledTimes(1));
@@ -111,6 +113,7 @@ describe("CustomFieldsFormSection", () => {
111
113
  });
112
114
 
113
115
  test("pre-fills inputs from initialValues (Edit zeigt den Bestand, nicht write-only)", async () => {
116
+ const user = userEvent.setup();
114
117
  mockedQueryRows = [
115
118
  {
116
119
  id: "f1",
@@ -153,12 +156,11 @@ describe("CustomFieldsFormSection", () => {
153
156
  expect(saveBtn.disabled).toBe(true);
154
157
 
155
158
  // Nur das geänderte Feld wird geschrieben, nicht der unveränderte Bestand.
156
- fireEvent.change(vendorInput, { target: { value: "Netcup" } });
159
+ await user.clear(vendorInput);
160
+ await user.type(vendorInput, "Netcup");
157
161
  expect(saveBtn.disabled).toBe(false);
158
- fireEvent.click(saveBtn);
159
- await Promise.resolve();
160
- await Promise.resolve();
161
- expect(dispatchSpy).toHaveBeenCalledTimes(1);
162
+ await user.click(saveBtn);
163
+ await waitFor(() => expect(dispatchSpy).toHaveBeenCalledTimes(1));
162
164
  expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
163
165
  entityName: "component",
164
166
  entityId: "row-42",
@@ -237,6 +239,7 @@ describe("CustomFieldsFormSection", () => {
237
239
 
238
240
  describe("CustomFieldsFormSection — clear-Pfad", () => {
239
241
  test("Leeren eines gespeicherten Werts dispatched clear-custom-field (nicht skip)", async () => {
242
+ const user = userEvent.setup();
240
243
  mockedQueryRows = [
241
244
  {
242
245
  id: "f1",
@@ -262,12 +265,9 @@ describe("CustomFieldsFormSection — clear-Pfad", () => {
262
265
  const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
263
266
  expect(vendorInput.value).toBe("Hetzner");
264
267
 
265
- fireEvent.change(vendorInput, { target: { value: "" } });
266
- fireEvent.click(screen.getByTestId("custom-fields-form-save"));
267
- await Promise.resolve();
268
- await Promise.resolve();
269
-
270
- expect(dispatchSpy).toHaveBeenCalledTimes(1);
268
+ await user.clear(vendorInput);
269
+ await user.click(screen.getByTestId("custom-fields-form-save"));
270
+ await waitFor(() => expect(dispatchSpy).toHaveBeenCalledTimes(1));
271
271
  expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:clear-custom-field", {
272
272
  entityName: "component",
273
273
  entityId: "row-42",
@@ -276,6 +276,7 @@ describe("CustomFieldsFormSection — clear-Pfad", () => {
276
276
  });
277
277
 
278
278
  test("unveränderter Bestandswert wird beim Save NICHT erneut geschrieben", async () => {
279
+ const user = userEvent.setup();
279
280
  mockedQueryRows = [
280
281
  {
281
282
  id: "f1",
@@ -300,18 +301,16 @@ describe("CustomFieldsFormSection — clear-Pfad", () => {
300
301
 
301
302
  const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
302
303
  // Tippen + zurück auf den Bestandswert → nicht dirty, kein Write.
303
- fireEvent.change(vendorInput, { target: { value: "Hetzner2" } });
304
- fireEvent.change(vendorInput, { target: { value: "Hetzner" } });
305
- fireEvent.click(screen.getByTestId("custom-fields-form-save"));
306
- await Promise.resolve();
307
- await Promise.resolve();
308
-
309
- expect(dispatchSpy).not.toHaveBeenCalled();
304
+ await user.type(vendorInput, "2");
305
+ await user.type(vendorInput, "{Backspace}");
306
+ await user.click(screen.getByTestId("custom-fields-form-save"));
307
+ await waitFor(() => expect(dispatchSpy).not.toHaveBeenCalled());
310
308
  });
311
309
  });
312
310
 
313
311
  describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
314
312
  test("boolean: Bestand wird als true/false-String angezeigt, Save coerced zu boolean", async () => {
313
+ const user = userEvent.setup();
315
314
  mockedQueryRows = [
316
315
  {
317
316
  id: "f1",
@@ -340,17 +339,16 @@ describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
340
339
  if (checkbox === null) throw new Error("boolean checkbox not rendered");
341
340
  expect(checkbox.getAttribute("aria-checked")).toBe("true");
342
341
 
343
- fireEvent.click(checkbox);
344
- fireEvent.click(screen.getByTestId("custom-fields-form-save"));
345
- await Promise.resolve();
346
- await Promise.resolve();
347
-
348
- expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
349
- entityName: "component",
350
- entityId: "row-42",
351
- fieldKey: "active",
352
- value: false,
353
- });
342
+ await user.click(checkbox);
343
+ await user.click(screen.getByTestId("custom-fields-form-save"));
344
+ await waitFor(() =>
345
+ expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
346
+ entityName: "component",
347
+ entityId: "row-42",
348
+ fieldKey: "active",
349
+ value: false,
350
+ }),
351
+ );
354
352
  });
355
353
 
356
354
  test("date: Bestand erreicht das DateInput-Textfeld (locale-numerisch)", () => {
@@ -368,3 +368,68 @@ describe("scenario 7: access rules on handlers", () => {
368
368
  });
369
369
  });
370
370
  });
371
+
372
+ // --- Scenario 8: entityList/entityEdit convention QNs ---
373
+ //
374
+ // The SystemAdmin tenant-list/tenant-edit screens resolve data through the
375
+ // entity-suffixed convention QNs (tenant:query:tenant:{list,detail},
376
+ // tenant:write:tenant:update), which were added alongside the legacy
377
+ // tenant:query:list / tenant:write:update handlers. The boot-validator does NOT
378
+ // check that an entityEdit has a matching update/detail handler, so this is the
379
+ // only thing that proves the screens have a live data path. Literal QNs on
380
+ // purpose — they ARE the wire contract the renderer computes.
381
+
382
+ describe("scenario 8: entityList/entityEdit convention QNs", () => {
383
+ test("tenant:query:tenant:list returns all tenants for SystemAdmin (systemScope)", async () => {
384
+ const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
385
+ "tenant:query:tenant:list",
386
+ {},
387
+ systemAdmin,
388
+ );
389
+ // acme + beta exist from earlier scenarios — systemScope yields all tenants.
390
+ expect(result.rows.length).toBeGreaterThanOrEqual(2);
391
+ expect(result.rows.map((r) => r["key"])).toEqual(expect.arrayContaining(["acme", "beta"]));
392
+ });
393
+
394
+ test("tenant:query:tenant:detail + tenant:write:tenant:update round-trip (entityEdit save persists)", async () => {
395
+ const created = await stack.http.writeOk(
396
+ "tenant:write:create",
397
+ { key: "delta", name: "Delta" },
398
+ systemAdmin,
399
+ );
400
+ const id = (created!["data"] as Record<string, unknown>)["id"] as string;
401
+
402
+ // entityEdit loads the row via the detail QN, edits, then saves via update.
403
+ const loaded = await stack.http.queryOk<Record<string, unknown>>(
404
+ "tenant:query:tenant:detail",
405
+ { id },
406
+ systemAdmin,
407
+ );
408
+ expect(loaded["name"]).toBe("Delta");
409
+
410
+ await stack.http.writeOk(
411
+ "tenant:write:tenant:update",
412
+ { id, version: loaded["version"], changes: { name: "Delta GmbH" } },
413
+ systemAdmin,
414
+ );
415
+
416
+ const reloaded = await stack.http.queryOk<Record<string, unknown>>(
417
+ "tenant:query:tenant:detail",
418
+ { id },
419
+ systemAdmin,
420
+ );
421
+ expect(reloaded["name"]).toBe("Delta GmbH");
422
+ });
423
+
424
+ test("the convention handlers are SystemAdmin-gated", () => {
425
+ expect(rolesOf(stack.registry.getQueryHandler("tenant:query:tenant:list")?.access)).toEqual([
426
+ "SystemAdmin",
427
+ ]);
428
+ expect(rolesOf(stack.registry.getQueryHandler("tenant:query:tenant:detail")?.access)).toEqual([
429
+ "SystemAdmin",
430
+ ]);
431
+ expect(rolesOf(stack.registry.getWriteHandler("tenant:write:tenant:update")?.access)).toEqual([
432
+ "SystemAdmin",
433
+ ]);
434
+ });
435
+ });
@@ -2,6 +2,9 @@ import {
2
2
  access,
3
3
  createSystemConfig,
4
4
  createTenantConfig,
5
+ defineEntityDetailHandler,
6
+ defineEntityListHandler,
7
+ defineEntityUpdateHandler,
5
8
  defineFeature,
6
9
  type FeatureDefinition,
7
10
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -22,6 +25,7 @@ import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
22
25
  import { tenantInvitationEntity } from "./invitation-table";
23
26
  import { tenantMembershipEntity } from "./membership-table";
24
27
  import { tenantEntity } from "./schema/tenant";
28
+ import { tenantEditScreen, tenantListScreen } from "./screens";
25
29
 
26
30
  export { tenantEntity, tenantTable } from "./schema/tenant";
27
31
 
@@ -113,6 +117,24 @@ export function createTenantFeature(): FeatureDefinition {
113
117
  invitations: r.queryHandler(invitationsQuery),
114
118
  };
115
119
 
120
+ // Entity-convention handlers for the SystemAdmin entityList/entityEdit
121
+ // screens. The feature's original handlers predate the `<entity>:<verb>`
122
+ // naming (they sit on tenant:query:list / tenant:write:update); entityList/
123
+ // entityEdit resolve tenant:query:tenant:{list,detail} + tenant:write:tenant:
124
+ // update by convention, so these are added alongside (no rename = no break
125
+ // for existing callers). Cross-tenant because the feature is systemScope.
126
+ r.queryHandler(
127
+ defineEntityListHandler("tenant", tenantEntity, { access: { roles: ["SystemAdmin"] } }),
128
+ );
129
+ r.queryHandler(
130
+ defineEntityDetailHandler("tenant", tenantEntity, { access: { roles: ["SystemAdmin"] } }),
131
+ );
132
+ r.writeHandler(
133
+ defineEntityUpdateHandler("tenant", tenantEntity, { access: { roles: ["SystemAdmin"] } }),
134
+ );
135
+ r.screen(tenantListScreen);
136
+ r.screen(tenantEditScreen);
137
+
116
138
  return { handlers, queries };
117
139
  });
118
140
  }
@@ -0,0 +1,46 @@
1
+ import type {
2
+ EntityEditScreenDefinition,
3
+ EntityListScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/engine";
5
+
6
+ // Cross-tenant SystemAdmin platform view of the tenants themselves. The tenant
7
+ // feature runs with `r.systemScope()`, so the entityList returns every tenant.
8
+ // Both screens are SystemAdmin-gated and inert until an app navs them.
9
+ //
10
+ // Backed by the entity-convention handlers registered in feature.ts
11
+ // (tenant:query:tenant:{list,detail}, tenant:write:tenant:update). The legacy
12
+ // `tenant:query:list` / `tenant:write:update` handlers stay for existing
13
+ // callers — these screens bind to the entity-suffixed QNs by convention.
14
+
15
+ export const tenantListScreen: EntityListScreenDefinition = {
16
+ id: "tenant-list",
17
+ type: "entityList",
18
+ entity: "tenant",
19
+ columns: ["key", "name", "isEnabled"],
20
+ rowActions: [
21
+ {
22
+ kind: "navigate",
23
+ id: "edit",
24
+ label: "kumiko.actions.edit",
25
+ screen: "tenant-edit",
26
+ entityId: "id",
27
+ },
28
+ ],
29
+ searchable: false,
30
+ access: { roles: ["SystemAdmin"] },
31
+ };
32
+
33
+ export const tenantEditScreen: EntityEditScreenDefinition = {
34
+ id: "tenant-edit",
35
+ type: "entityEdit",
36
+ entity: "tenant",
37
+ layout: {
38
+ // `key` is the unique admin-URL slug — shown in the list, not editable here.
39
+ sections: [{ columns: 2, fields: ["name", "isEnabled"] }],
40
+ },
41
+ // No raw tenant creation (onboarding owns membership/owner setup) and no
42
+ // hard delete (no tenant:write:tenant:delete — disable via isEnabled instead).
43
+ allowCreate: false,
44
+ allowDelete: false,
45
+ access: { roles: ["SystemAdmin"] },
46
+ };
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createConfigFeature } from "../../config/feature";
4
+ import { createTenantFeature } from "../../tenant/feature";
5
+ import { createUserFeature } from "../feature";
6
+
7
+ // The SystemAdmin platform screens (entityList + entityEdit for user/tenant)
8
+ // must live IN the user/tenant features — the boot-validator forbids
9
+ // cross-feature screen ownership. The validator checks screen STRUCTURE
10
+ // (entity-local, columns/fields exist, rowAction targets resolve) but NOT that
11
+ // an entityEdit has a matching update/detail handler. That convention-QN wiring
12
+ // is the load-bearing part here, so it is asserted explicitly.
13
+ //
14
+ // QN convention (collectWriteHandlerQns / collectScreenQns): a handler keyed
15
+ // "<short>" in feature "<f>" resolves to "<f>:<kind>:<short>". entityList loads
16
+ // "<f>:query:<entity>:list", entityEdit loads "<f>:query:<entity>:detail" and
17
+ // saves via "<f>:write:<entity>:{create,update}".
18
+
19
+ describe("user + tenant SystemAdmin admin screens", () => {
20
+ const features = [createConfigFeature(), createUserFeature(), createTenantFeature()];
21
+
22
+ test("the assembled feature set boot-validates", () => {
23
+ expect(() => validateBoot(features)).not.toThrow();
24
+ });
25
+
26
+ test("user feature ships SystemAdmin-gated list + edit screens", () => {
27
+ const user = createUserFeature();
28
+ expect(Object.keys(user.screens)).toEqual(expect.arrayContaining(["user-list", "user-edit"]));
29
+ const list = user.screens["user-list"];
30
+ expect(list?.type).toBe("entityList");
31
+ expect(list?.access).toEqual({ roles: ["SystemAdmin"] });
32
+ expect(user.screens["user-edit"]?.type).toBe("entityEdit");
33
+ });
34
+
35
+ test("user list/detail/create/update handlers already sit on the screen QNs", () => {
36
+ const user = createUserFeature();
37
+ // → user:query:user:list, user:query:user:detail
38
+ expect(Object.keys(user.queryHandlers)).toEqual(
39
+ expect.arrayContaining(["user:list", "user:detail"]),
40
+ );
41
+ // → user:write:user:update (entityEdit save), user:write:user:create ("+ New")
42
+ expect(Object.keys(user.writeHandlers)).toEqual(
43
+ expect.arrayContaining(["user:update", "user:create"]),
44
+ );
45
+ });
46
+
47
+ test("tenant feature ships list + edit screens (edit-only, no hard delete)", () => {
48
+ const tenant = createTenantFeature();
49
+ expect(Object.keys(tenant.screens)).toEqual(
50
+ expect.arrayContaining(["tenant-list", "tenant-edit"]),
51
+ );
52
+ const edit = tenant.screens["tenant-edit"];
53
+ expect(edit?.type).toBe("entityEdit");
54
+ if (edit?.type === "entityEdit") {
55
+ expect(edit.allowCreate).toBe(false);
56
+ expect(edit.allowDelete).toBe(false);
57
+ }
58
+ });
59
+
60
+ test("tenant gains entity-convention handlers without dropping the legacy ones", () => {
61
+ const tenant = createTenantFeature();
62
+ // New: entityList/entityEdit resolve tenant:query:tenant:{list,detail} +
63
+ // tenant:write:tenant:update (the legacy handlers are keyed "list"/"update"
64
+ // → tenant:query:list / tenant:write:update, which the convention misses).
65
+ expect(Object.keys(tenant.queryHandlers)).toEqual(
66
+ expect.arrayContaining(["tenant:list", "tenant:detail"]),
67
+ );
68
+ expect(Object.keys(tenant.writeHandlers)).toContain("tenant:update");
69
+ // Legacy handlers stay for existing callers (no rename = no break).
70
+ expect(Object.keys(tenant.queryHandlers)).toContain("list");
71
+ expect(Object.keys(tenant.writeHandlers)).toContain("update");
72
+ });
73
+ });
@@ -6,6 +6,7 @@ import { listQuery } from "./handlers/list.query";
6
6
  import { meQuery } from "./handlers/me.query";
7
7
  import { updateWrite } from "./handlers/update.write";
8
8
  import { userEntity } from "./schema/user";
9
+ import { userEditScreen, userListScreen } from "./screens";
9
10
 
10
11
  // The user feature holds the cross-tenant user identity. `systemScope()` means
11
12
  // queries and writes bypass the tenant filter — a user exists above any tenant.
@@ -35,6 +36,13 @@ export function createUserFeature(): FeatureDefinition {
35
36
  findForAuth: r.queryHandler(findForAuthQuery),
36
37
  };
37
38
 
39
+ // Cross-tenant SystemAdmin platform screens. Inert until an app navs them;
40
+ // list/detail/create/update handlers above already sit on the QNs that
41
+ // entityList/entityEdit resolve by convention (user:query:user:{list,detail},
42
+ // user:write:user:{create,update}).
43
+ r.screen(userListScreen);
44
+ r.screen(userEditScreen);
45
+
38
46
  return { handlers, queries };
39
47
  });
40
48
  }
@@ -0,0 +1,57 @@
1
+ import type {
2
+ EntityEditScreenDefinition,
3
+ EntityListScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/engine";
5
+
6
+ // Cross-tenant platform admin view of the user identity. Because the user
7
+ // feature runs with `r.systemScope()`, the entityList query returns every
8
+ // user across all tenants — the SystemAdmin platform roster. Both screens are
9
+ // SystemAdmin-gated and stay inert until an app navs them (no auto-nav).
10
+ //
11
+ // Field labels come from the renderer's humanizeSlug fallback (no i18n keys
12
+ // registered) — "Display Name", "Email Verified" etc. Apps can override via
13
+ // their own translations under the `user:entity:user:field:*` convention.
14
+
15
+ export const userListScreen: EntityListScreenDefinition = {
16
+ id: "user-list",
17
+ type: "entityList",
18
+ entity: "user",
19
+ columns: ["email", "displayName", "status", "emailVerified"],
20
+ rowActions: [
21
+ {
22
+ kind: "navigate",
23
+ id: "edit",
24
+ label: "kumiko.actions.edit",
25
+ screen: "user-edit",
26
+ entityId: "id",
27
+ },
28
+ ],
29
+ // No SearchAdapter assumption: search is opt-in per app infra, not a
30
+ // universal default for a bundled screen.
31
+ searchable: false,
32
+ access: { roles: ["SystemAdmin"] },
33
+ };
34
+
35
+ export const userEditScreen: EntityEditScreenDefinition = {
36
+ id: "user-edit",
37
+ type: "entityEdit",
38
+ entity: "user",
39
+ layout: {
40
+ sections: [
41
+ {
42
+ columns: 2,
43
+ fields: ["email", "displayName", "locale", "emailVerified"],
44
+ },
45
+ ],
46
+ },
47
+ // `roles` is deliberately NOT editable here: it is a raw JSON text column
48
+ // (`["SystemAdmin"]`) — a free-text input would let a typo corrupt the
49
+ // privilege column on a live platform. Role management needs a dedicated
50
+ // surface; the list still shows status for triage.
51
+ //
52
+ // Create dispatches user:write:user:create (email + displayName required —
53
+ // both in the form). Delete is suppressed: there is no user:write:user:delete
54
+ // — user removal is the GDPR status/forget flow, not a hard delete.
55
+ allowDelete: false,
56
+ access: { roles: ["SystemAdmin"] },
57
+ };
@@ -37,10 +37,8 @@ function failureKey(error: unknown): string {
37
37
  return typeof key === "string" ? key : "profile.errors.generic";
38
38
  }
39
39
 
40
- // gracePeriodEnd ist ein roher ISO-Instant ("2026-07-11T00:00:00.000Z"); nur
41
- // der Datums-Teil ist für den User relevant, die Uhrzeit/Z wäre Rauschen
42
- // ("…am 2026-07-11T00:00:00.000Z gelöscht"). Reiner String-Slice — kein
43
- // Date-API (no-date-api-Guard) und universell (RN+Web). Leer/null → "—".
40
+ // Date-only slice of the raw ISO instant — the time/Z part is noise to the user.
41
+ // Pure string-slice: no Date API (no-date-api guard), universal (RN+Web). Empty → "—".
44
42
  export function formatDeletionDate(iso: string | null | undefined): string {
45
43
  if (!iso) return "—";
46
44
  const tIndex = iso.indexOf("T");