@cosmicdrift/kumiko-bundled-features 0.232.0 → 0.233.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.232.0",
3
+ "version": "0.233.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>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.232.0",
134
- "@cosmicdrift/kumiko-framework": "0.232.0",
135
- "@cosmicdrift/kumiko-headless": "0.232.0",
136
- "@cosmicdrift/kumiko-renderer": "0.232.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.232.0",
138
- "@cosmicdrift/kumiko-types": "0.232.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.233.0",
134
+ "@cosmicdrift/kumiko-framework": "0.233.0",
135
+ "@cosmicdrift/kumiko-headless": "0.233.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.233.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.233.0",
138
+ "@cosmicdrift/kumiko-types": "0.233.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.232.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.232.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.233.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.233.0"
169
169
  }
170
170
  }
@@ -22,6 +22,10 @@ describe("computeFraction", () => {
22
22
  test("used 0 of a positive limit -> 0", () => {
23
23
  expect(computeFraction(0, 5)).toBe(0);
24
24
  });
25
+
26
+ test("limit null (unlimited) -> 0, regardless of used", () => {
27
+ expect(computeFraction(165, null)).toBe(0);
28
+ });
25
29
  });
26
30
 
27
31
  describe("computeUnclampedFraction", () => {
@@ -37,6 +41,10 @@ describe("computeUnclampedFraction", () => {
37
41
  test("used 3 of limit 5 -> 0.6, same as the clamped case", () => {
38
42
  expect(computeUnclampedFraction(3, 5)).toBe(0.6);
39
43
  });
44
+
45
+ test("limit null (unlimited) -> 0, regardless of used", () => {
46
+ expect(computeUnclampedFraction(165, null)).toBe(0);
47
+ });
40
48
  });
41
49
 
42
50
  describe("computeTone", () => {
@@ -57,4 +65,8 @@ describe("computeTone", () => {
57
65
  test("fraction >= 1 -> danger", () => {
58
66
  expect(computeTone(1)).toBe("danger");
59
67
  });
68
+
69
+ test("unlimited (limit null) never escalates to warn/danger", () => {
70
+ expect(computeTone(computeFraction(165, null))).toBe("default");
71
+ });
60
72
  });
@@ -4,5 +4,11 @@
4
4
  "type": "improvement",
5
5
  "title": "Add cap-overview feature: tier + cap usage visibility per tenant.",
6
6
  "detail": "New read-only bundled feature. SystemAdmin gets a paged, searchable tenant list with tier/billing columns and per-cap usage bars, plus a per-tenant dashboard reachable from the list. TenantAdmin gets a 'My usage' dashboard showing their own tenant's cap usage as cards. Reads tier-engine, billing-foundation and tenant projections plus caller-supplied CapSpec usage callbacks; every handler enforces explicit tenant scoping (no ambient tenant filter, cross-tenant reads require SystemAdmin + acknowledgeCrossTenant)."
7
+ },
8
+ {
9
+ "version": "0.233.0",
10
+ "type": "improvement",
11
+ "title": "CapSpec.limit can now return null for an unlimited usage meter.",
12
+ "detail": "A CapSpec's `limit(tier)` may now return `null` to mean unlimited: the cap is still counted (used shows the raw count) but never capped, has no percent, and renders no progress bar or badge. This is a distinct meaning from `limit <= 0`, which unchanged still means 'not part of this tier' and renders as 0/0%."
7
13
  }
8
14
  ]
@@ -86,7 +86,8 @@ export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefi
86
86
  limit,
87
87
  fraction,
88
88
  tone: computeTone(fraction),
89
- percent: Math.round(computeUnclampedFraction(used, limit) * 100),
89
+ percent:
90
+ limit === null ? null : Math.round(computeUnclampedFraction(used, limit) * 100),
90
91
  ...(cap.icon !== undefined && { icon: cap.icon }),
91
92
  ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
92
93
  };
@@ -14,7 +14,9 @@ export type CapIconKey = "file" | "hash" | "database" | "users" | "mail" | "gaug
14
14
  export type CapSpec = {
15
15
  readonly id: string;
16
16
  readonly label: string;
17
- readonly limit: (tier: string) => number;
17
+ // `null` = unlimited (a pure usage meter: counted but never capped).
18
+ // `<= 0` = not part of this tier — existing, unchanged meaning (usage-math.ts).
19
+ readonly limit: (tier: string) => number | null;
18
20
  // `null` means "no measurement exists yet for this cap" (not "0 used") —
19
21
  // callers that haven't wired up instrumentation return null instead of a
20
22
  // number they don't actually have.
@@ -44,7 +46,8 @@ export type CapUsage = {
44
46
  // `null` means not measured yet — `fraction`/`tone` stay non-nullable
45
47
  // (0 / "default") so no consumer needs null-arithmetic to render.
46
48
  readonly used: number | null;
47
- readonly limit: number;
49
+ // `limit: null` means unlimited — a pure usage meter with no cap to hit.
50
+ readonly limit: number | null;
48
51
  readonly fraction: number;
49
52
  };
50
53
 
@@ -7,21 +7,23 @@ import type { CapUsageTone } from "./types";
7
7
  const WARN_THRESHOLD = 0.8;
8
8
  const DANGER_THRESHOLD = 1;
9
9
 
10
- // limit <= 0 means "not part of this tier" — 0, never NaN/Infinity.
11
- function rawFraction(used: number, limit: number): number {
10
+ // limit === null means unlimited (no cap to measure against); limit <= 0
11
+ // means "not part of this tier" — both render as 0, never NaN/Infinity.
12
+ function rawFraction(used: number, limit: number | null): number {
13
+ if (limit === null) return 0;
12
14
  if (limit <= 0) return 0;
13
15
  const fraction = used / limit;
14
16
  return Number.isFinite(fraction) ? fraction : 0;
15
17
  }
16
18
 
17
- export function computeFraction(used: number, limit: number): number {
19
+ export function computeFraction(used: number, limit: number | null): number {
18
20
  return Math.min(1, Math.max(0, rawFraction(used, limit)));
19
21
  }
20
22
 
21
23
  // Unclamped — for display of the over-limit case (e.g. "140%"). Bar width
22
24
  // and tone still use the clamped computeFraction; only percent shows the
23
25
  // real ratio.
24
- export function computeUnclampedFraction(used: number, limit: number): number {
26
+ export function computeUnclampedFraction(used: number, limit: number | null): number {
25
27
  return rawFraction(used, limit);
26
28
  }
27
29
 
@@ -96,6 +96,19 @@ function nullCapRow(overrides: Partial<CapUsageWithMeta> = {}): CapUsageWithMeta
96
96
  };
97
97
  }
98
98
 
99
+ function unlimitedCapRow(overrides: Partial<CapUsageWithMeta> = {}): CapUsageWithMeta {
100
+ return {
101
+ id: "cap-unlimited",
102
+ label: "cap.label.unlimited",
103
+ tone: "default",
104
+ percent: null,
105
+ used: 165,
106
+ limit: null,
107
+ fraction: 0,
108
+ ...overrides,
109
+ };
110
+ }
111
+
99
112
  beforeEach(() => {
100
113
  queryState = { data: { rows: [] }, loading: false, error: null };
101
114
  useQuerySpy.mockClear();
@@ -233,6 +246,46 @@ describe("CapCardsPanel", () => {
233
246
  expect(screen.getAllByTestId("cap-usage-bar")).toHaveLength(1);
234
247
  });
235
248
 
249
+ test("an unlimited cap (limit: null) shows the raw used count, no denominator, no percent badge, no bar", () => {
250
+ queryState = {
251
+ data: { rows: [unlimitedCapRow()] },
252
+ loading: false,
253
+ error: null,
254
+ };
255
+
256
+ renderPanel(undefined);
257
+
258
+ expect(screen.getByTestId("cap-card").textContent).toContain("165");
259
+ expect(screen.queryByText("165 / null")).toBeNull();
260
+ expect(screen.queryByText("165 / 0")).toBeNull();
261
+ expect(screen.queryByText("null%")).toBeNull();
262
+ expect(screen.queryByText("0%")).toBeNull();
263
+ expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
264
+ });
265
+
266
+ test("a limited card and an unlimited card in the same panel each render their own state", () => {
267
+ queryState = {
268
+ data: {
269
+ rows: [
270
+ capRow({ id: "c1", label: "Storage", used: 3, limit: 10, tone: "default", percent: 30 }),
271
+ unlimitedCapRow({ id: "c2", label: "Api calls" }),
272
+ ],
273
+ },
274
+ loading: false,
275
+ error: null,
276
+ };
277
+
278
+ renderPanel(undefined);
279
+
280
+ expect(screen.getAllByTestId("cap-card")).toHaveLength(2);
281
+ expect(screen.getByText("3 / 10")).toBeTruthy();
282
+ expect(screen.getByText("30%")).toBeTruthy();
283
+ expect(screen.getAllByTestId("cap-usage-bar")).toHaveLength(1);
284
+ expect(screen.getAllByTestId("cap-card")[1]?.textContent).toContain("165");
285
+ expect(screen.queryByText("165 / null")).toBeNull();
286
+ expect(screen.queryByText("null%")).toBeNull();
287
+ });
288
+
236
289
  test("loading with empty rows shows the loading text instead of the empty state", () => {
237
290
  queryState = { data: null, loading: true, error: null };
238
291
 
@@ -73,4 +73,40 @@ describe("CapUsageBar", () => {
73
73
  expect(fillClass()).not.toContain("bg-primary");
74
74
  expect(fillClass()).not.toContain("bg-status-critical");
75
75
  });
76
+
77
+ test("renders the raw used count instead of a bar when limit is null (unlimited)", () => {
78
+ const usage: CapUsage = { used: 165, limit: null, fraction: 0 };
79
+ render(
80
+ <Wrapper>
81
+ <CapUsageBar usage={usage} />
82
+ </Wrapper>,
83
+ );
84
+
85
+ expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
86
+ expect(screen.getByTestId("cap-usage-unlimited").textContent).toBe("165");
87
+ });
88
+
89
+ test("renders nothing for an unlimited cap when showLabel is false", () => {
90
+ const usage: CapUsage = { used: 165, limit: null, fraction: 0 };
91
+ render(
92
+ <Wrapper>
93
+ <CapUsageBar usage={usage} showLabel={false} />
94
+ </Wrapper>,
95
+ );
96
+
97
+ expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
98
+ expect(screen.queryByTestId("cap-usage-unlimited")).toBeNull();
99
+ });
100
+
101
+ test("not-measured wins over unlimited when both used and limit are null", () => {
102
+ const usage: CapUsage = { used: null, limit: null, fraction: 0 };
103
+ render(
104
+ <Wrapper>
105
+ <CapUsageBar usage={usage} />
106
+ </Wrapper>,
107
+ );
108
+
109
+ expect(screen.getByTestId("cap-usage-not-measured")).toBeTruthy();
110
+ expect(screen.queryByTestId("cap-usage-unlimited")).toBeNull();
111
+ });
76
112
  });
@@ -35,11 +35,9 @@ const CAP_ICONS: Record<CapIconKey, ComponentType<{ className?: string }>> = {
35
35
  gauge: Gauge,
36
36
  };
37
37
 
38
- // Narrows `percent` to non-null alongside `used` — the query handler only
39
- // ever sets both or neither, but the type doesn't encode that link.
40
- function isMeasured(
41
- cap: CapUsageWithMeta,
42
- ): cap is CapUsageWithMeta & { used: number; percent: number } {
38
+ // Narrows `used` only — `percent` can independently be null for an
39
+ // unlimited cap (limit: null) even though used is measured.
40
+ function isMeasured(cap: CapUsageWithMeta): cap is CapUsageWithMeta & { used: number } {
43
41
  return cap.used !== null;
44
42
  }
45
43
 
@@ -89,12 +87,18 @@ export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNod
89
87
  key={cap.id}
90
88
  icon={Icon !== undefined ? <Icon className="size-4" /> : undefined}
91
89
  label={t(cap.label)}
92
- value={isMeasured(cap) ? `${cap.used} / ${cap.limit}` : "—"}
90
+ value={
91
+ !isMeasured(cap)
92
+ ? "—"
93
+ : cap.limit === null
94
+ ? String(cap.used)
95
+ : `${cap.used} / ${cap.limit}`
96
+ }
93
97
  {...(cap.accentColor !== undefined && { accentColor: cap.accentColor })}
94
98
  testId="cap-card"
95
99
  >
96
100
  <div className="flex flex-col gap-2">
97
- {isMeasured(cap) && (
101
+ {cap.percent !== null && (
98
102
  <div className="flex items-center justify-between gap-2">
99
103
  <StatusBadge tone={TONE_TO_STATUS[cap.tone]}>{`${cap.percent}%`}</StatusBadge>
100
104
  </div>
@@ -24,6 +24,15 @@ export function CapUsageBar({
24
24
  );
25
25
  }
26
26
 
27
+ if (usage.limit === null) {
28
+ // A progress bar with no denominator says nothing — show the raw count instead.
29
+ return showLabel ? (
30
+ <Text variant="small" testId="cap-usage-unlimited">
31
+ {String(usage.used)}
32
+ </Text>
33
+ ) : null;
34
+ }
35
+
27
36
  return (
28
37
  <div className="flex flex-col gap-1">
29
38
  {Progress ? (
@@ -7,9 +7,10 @@ import { CapUsageBar } from "./cap-usage-bar";
7
7
  function isCapUsage(value: unknown): value is CapUsage {
8
8
  if (typeof value !== "object" || value === null) return false;
9
9
  const used = (value as { used?: unknown }).used;
10
+ const limit = (value as { limit?: unknown }).limit;
10
11
  return (
11
12
  (typeof used === "number" || used === null) &&
12
- typeof (value as { limit?: unknown }).limit === "number" &&
13
+ (typeof limit === "number" || limit === null) &&
13
14
  typeof (value as { fraction?: unknown }).fraction === "number"
14
15
  );
15
16
  }
@@ -36,6 +36,19 @@ describe("sessions screens + query access alignment (kumiko-framework#255)", ()
36
36
  }
37
37
  });
38
38
 
39
+ test("session-list timestamp columns declare renderer.format (fw#2569)", () => {
40
+ const sessions = createSessionsFeature();
41
+ const list = sessions.screens[SESSION_LIST_SCREEN_ID];
42
+ if (list?.type !== "projectionList") throw new Error("expected projectionList");
43
+ for (const field of ["createdAt", "expiresAt", "revokedAt"] as const) {
44
+ const column = list.columns.find(
45
+ (c): c is Exclude<(typeof list.columns)[number], string> =>
46
+ typeof c !== "string" && c.field === field,
47
+ );
48
+ expect(column?.renderer).toEqual({ format: "timestamp" });
49
+ }
50
+ });
51
+
39
52
  test("session-list row-action navigates to session-detail with entityId 'id'", () => {
40
53
  const sessions = createSessionsFeature();
41
54
  const list = sessions.screens[SESSION_LIST_SCREEN_ID];
@@ -4,12 +4,7 @@ import {
4
4
  type SessionStoreProvider,
5
5
  } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
6
6
  import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
7
- import {
8
- access,
9
- defineFeature,
10
- type FeatureDefinition,
11
- } from "@cosmicdrift/kumiko-framework/engine";
12
- import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SessionQueries } from "./constants";
7
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
13
8
  import { cleanupJob } from "./handlers/cleanup.job";
14
9
  import { detailQuery } from "./handlers/detail.query";
15
10
  import { listQuery } from "./handlers/list.query";
@@ -19,6 +14,7 @@ import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
19
14
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
20
15
  import { SESSIONS_I18N } from "./i18n";
21
16
  import { userSessionEntity } from "./schema/user-session";
17
+ import { sessionDetailScreen, sessionListScreen } from "./screens";
22
18
  import {
23
19
  createSessionCallbacks,
24
20
  type SessionAllOthersRevoker,
@@ -211,70 +207,8 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
211
207
 
212
208
  r.translations({ keys: SESSIONS_I18N });
213
209
 
214
- const listAccess = { roles: access.admin };
215
-
216
- r.screen({
217
- id: SESSION_LIST_SCREEN_ID,
218
- type: "projectionList",
219
- query: SessionQueries.list,
220
- // Mirrors list.query's own fallback (unrecognised/absent sort → createdAt
221
- // desc) — kept in sync by hand, boot-validator only requires the field
222
- // be present once the query accepts `sort` (fw#2230).
223
- defaultSort: { field: "createdAt", dir: "desc" },
224
- columns: [
225
- { field: "id", label: "sessions.list.col.id" },
226
- { field: "userId", label: "sessions.list.col.userId" },
227
- { field: "createdAt", label: "sessions.list.col.createdAt" },
228
- { field: "expiresAt", label: "sessions.list.col.expiresAt" },
229
- { field: "revokedAt", label: "sessions.list.col.revokedAt" },
230
- ],
231
- rowActions: [
232
- {
233
- kind: "navigate",
234
- id: "open",
235
- label: "sessions.list.action.open",
236
- screen: SESSION_DETAIL_SCREEN_ID,
237
- entityId: "id",
238
- rowClick: true,
239
- },
240
- ],
241
- access: listAccess,
242
- });
243
- r.screen({
244
- id: SESSION_DETAIL_SCREEN_ID,
245
- type: "projectionDetail",
246
- query: SessionQueries.detail,
247
- listScreenId: SESSION_LIST_SCREEN_ID,
248
- layout: {
249
- sections: [
250
- {
251
- fields: [
252
- "id",
253
- "userId",
254
- // The shim (projection-detail-shim.ts) stamps every field as
255
- // type:"text" — field.renderer is the only way this screen
256
- // type reaches real per-type formatting instead of a raw ISO
257
- // string (fw#2245).
258
- { field: "createdAt", renderer: { format: "timestamp" } },
259
- { field: "expiresAt", renderer: { format: "timestamp" } },
260
- { field: "revokedAt", renderer: { format: "timestamp" } },
261
- "ip",
262
- "userAgent",
263
- ],
264
- },
265
- ],
266
- },
267
- fieldLabels: {
268
- id: "sessions.detail.field.id",
269
- userId: "sessions.detail.field.userId",
270
- createdAt: "sessions.detail.field.createdAt",
271
- expiresAt: "sessions.detail.field.expiresAt",
272
- revokedAt: "sessions.detail.field.revokedAt",
273
- ip: "sessions.detail.field.ip",
274
- userAgent: "sessions.detail.field.userAgent",
275
- },
276
- access: listAccess,
277
- });
210
+ r.screen(sessionListScreen);
211
+ r.screen(sessionDetailScreen);
278
212
  r.nav({
279
213
  id: "session-list",
280
214
  label: "sessions:nav.sessionList",
@@ -0,0 +1,84 @@
1
+ import {
2
+ access,
3
+ type ProjectionDetailScreenDefinition,
4
+ type ProjectionListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SessionQueries } from "./constants";
7
+
8
+ const listAccess = { roles: access.admin };
9
+
10
+ export const sessionListScreen: ProjectionListScreenDefinition = {
11
+ id: SESSION_LIST_SCREEN_ID,
12
+ type: "projectionList",
13
+ query: SessionQueries.list,
14
+ // Mirrors list.query's own fallback (unrecognised/absent sort → createdAt
15
+ // desc) — kept in sync by hand, boot-validator only requires the field
16
+ // be present once the query accepts `sort` (fw#2230).
17
+ defaultSort: { field: "createdAt", dir: "desc" },
18
+ columns: [
19
+ { field: "id", label: "sessions.list.col.id" },
20
+ { field: "userId", label: "sessions.list.col.userId" },
21
+ {
22
+ field: "createdAt",
23
+ label: "sessions.list.col.createdAt",
24
+ renderer: { format: "timestamp" },
25
+ },
26
+ {
27
+ field: "expiresAt",
28
+ label: "sessions.list.col.expiresAt",
29
+ renderer: { format: "timestamp" },
30
+ },
31
+ {
32
+ field: "revokedAt",
33
+ label: "sessions.list.col.revokedAt",
34
+ renderer: { format: "timestamp" },
35
+ },
36
+ ],
37
+ rowActions: [
38
+ {
39
+ kind: "navigate",
40
+ id: "open",
41
+ label: "sessions.list.action.open",
42
+ screen: SESSION_DETAIL_SCREEN_ID,
43
+ entityId: "id",
44
+ rowClick: true,
45
+ },
46
+ ],
47
+ access: listAccess,
48
+ };
49
+
50
+ export const sessionDetailScreen: ProjectionDetailScreenDefinition = {
51
+ id: SESSION_DETAIL_SCREEN_ID,
52
+ type: "projectionDetail",
53
+ query: SessionQueries.detail,
54
+ listScreenId: SESSION_LIST_SCREEN_ID,
55
+ layout: {
56
+ sections: [
57
+ {
58
+ fields: [
59
+ "id",
60
+ "userId",
61
+ // The shim (projection-detail-shim.ts) stamps every field as
62
+ // type:"text" — field.renderer is the only way this screen
63
+ // type reaches real per-type formatting instead of a raw ISO
64
+ // string (fw#2245).
65
+ { field: "createdAt", renderer: { format: "timestamp" } },
66
+ { field: "expiresAt", renderer: { format: "timestamp" } },
67
+ { field: "revokedAt", renderer: { format: "timestamp" } },
68
+ "ip",
69
+ "userAgent",
70
+ ],
71
+ },
72
+ ],
73
+ },
74
+ fieldLabels: {
75
+ id: "sessions.detail.field.id",
76
+ userId: "sessions.detail.field.userId",
77
+ createdAt: "sessions.detail.field.createdAt",
78
+ expiresAt: "sessions.detail.field.expiresAt",
79
+ revokedAt: "sessions.detail.field.revokedAt",
80
+ ip: "sessions.detail.field.ip",
81
+ userAgent: "sessions.detail.field.userAgent",
82
+ },
83
+ access: listAccess,
84
+ };
@@ -54,6 +54,20 @@ describe("tenant members screen + handler access alignment", () => {
54
54
  }
55
55
  });
56
56
 
57
+ test("members screen timestamp columns declare renderer.format (fw#2569)", () => {
58
+ const tenant = createTenantFeature();
59
+ const screen = tenant.screens[MEMBERS_SCREEN_ID];
60
+ if (screen?.type !== "projectionList")
61
+ throw new Error("expected members screen to be projectionList");
62
+ for (const field of ["createdAt", "lastSeenAt"] as const) {
63
+ const column = screen.columns.find(
64
+ (c): c is Exclude<(typeof screen.columns)[number], string> =>
65
+ typeof c !== "string" && c.field === field,
66
+ );
67
+ expect(column?.renderer).toEqual({ format: "timestamp" });
68
+ }
69
+ });
70
+
57
71
  test("cancel-invitation row action is only visible on pending rows and cancels via TenantHandlers.cancelInvitation", () => {
58
72
  const tenant = createTenantFeature();
59
73
  const screen = tenant.screens[MEMBERS_SCREEN_ID];
@@ -91,8 +91,16 @@ export function createMembersScreen(options?: {
91
91
  label: "tenant.members.col.status",
92
92
  renderer: { react: { __component: MEMBER_STATUS_CELL_COMPONENT } },
93
93
  },
94
- { field: "createdAt", label: "tenant.members.col.created" },
95
- { field: "lastSeenAt", label: "tenant.members.col.lastActivity" },
94
+ {
95
+ field: "createdAt",
96
+ label: "tenant.members.col.created",
97
+ renderer: { format: "timestamp" },
98
+ },
99
+ {
100
+ field: "lastSeenAt",
101
+ label: "tenant.members.col.lastActivity",
102
+ renderer: { format: "timestamp" },
103
+ },
96
104
  ],
97
105
  defaultSort: { field: "createdAt", dir: "desc" },
98
106
  searchable: true,