@cosmicdrift/kumiko-renderer 0.291.0 → 0.293.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-renderer",
3
- "version": "0.291.0",
3
+ "version": "0.293.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.291.0",
19
- "@cosmicdrift/kumiko-headless": "0.291.0",
20
- "@cosmicdrift/kumiko-types": "0.291.0",
18
+ "@cosmicdrift/kumiko-framework": "0.293.0",
19
+ "@cosmicdrift/kumiko-headless": "0.293.0",
20
+ "@cosmicdrift/kumiko-types": "0.293.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.291.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.293.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -183,6 +183,55 @@ describe("useListUrlState", () => {
183
183
  });
184
184
  });
185
185
 
186
+ // fw#3104: the date-range facet lives in the same .f. namespace so
187
+ // clearFilters covers it — but both bounds have to land in ONE params
188
+ // update, or the second call overwrites the first.
189
+ test("setDateRange schreibt beide Grenzen atomar + resettet page", () => {
190
+ const nav = makeNav({ "orders.page": "4" });
191
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
192
+ act(() => {
193
+ result.current.setDateRange("createdAt", { from: "2020-06-10", to: "2020-06-14" });
194
+ });
195
+ expect(nav.captures).toHaveLength(1);
196
+ expect(nav.captures[0]).toEqual({
197
+ "orders.f.createdAt.from": "2020-06-10",
198
+ "orders.f.createdAt.to": "2020-06-14",
199
+ "orders.page": null,
200
+ });
201
+ });
202
+
203
+ test("setDateRange mit leerer Grenze löscht nur diesen Key", () => {
204
+ const nav = makeNav({
205
+ "orders.f.createdAt.from": "2020-06-10",
206
+ "orders.f.createdAt.to": "2020-06-14",
207
+ });
208
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
209
+ act(() => {
210
+ result.current.setDateRange("createdAt", { from: "", to: "2020-06-14" });
211
+ });
212
+ expect(nav.captures[0]).toEqual({
213
+ "orders.f.createdAt.from": null,
214
+ "orders.f.createdAt.to": "2020-06-14",
215
+ "orders.page": null,
216
+ });
217
+ });
218
+
219
+ test("clearFilters löscht auch die Zeitraum-Grenzen", () => {
220
+ const nav = makeNav({
221
+ "orders.f.createdAt.from": "2020-06-10",
222
+ "orders.f.createdAt.to": "2020-06-14",
223
+ });
224
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
225
+ act(() => {
226
+ result.current.clearFilters();
227
+ });
228
+ expect(nav.captures[0]).toEqual({
229
+ "orders.page": null,
230
+ "orders.f.createdAt.from": null,
231
+ "orders.f.createdAt.to": null,
232
+ });
233
+ });
234
+
186
235
  test("clearFilters löscht alle aktiven .f.<field>-Keys", () => {
187
236
  const nav = makeNav({
188
237
  "orders.f.status": "draft",
@@ -0,0 +1,165 @@
1
+ // fw#3104: a dateRange facet turns two calendar dates into the two query
2
+ // params it declares. The conversion is the whole risk surface — a calendar
3
+ // date has no time, `createdAt` is an instant, and "to the 14th" has to mean
4
+ // "through the end of the 14th" in the viewer's zone, not "at 00:00".
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
8
+ import { Temporal } from "temporal-polyfill";
9
+ import {
10
+ buildDateRangePayload,
11
+ clampDateRange,
12
+ readDateRange,
13
+ resolveDateRangeFacets,
14
+ resolveProjectionFacetSpecs,
15
+ } from "../list-facets";
16
+
17
+ const VIENNA = "Europe/Vienna";
18
+
19
+ const auditFacets: readonly ListFacetSpec[] = [
20
+ {
21
+ type: "dateRange",
22
+ field: "createdAt",
23
+ label: "audit.log.col.when",
24
+ params: { from: "from", to: "to" },
25
+ },
26
+ ];
27
+
28
+ const specs = resolveDateRangeFacets(auditFacets, (key) => key);
29
+
30
+ function payload(
31
+ filters: Record<string, readonly string[]>,
32
+ timeZone = VIENNA,
33
+ ): Record<string, string> {
34
+ return buildDateRangePayload(specs, filters, timeZone);
35
+ }
36
+
37
+ describe("dateRange facet resolution", () => {
38
+ test("resolves the declared param names, not a from/to convention", () => {
39
+ const resolved = resolveDateRangeFacets(
40
+ [
41
+ {
42
+ type: "dateRange",
43
+ field: "occurredAt",
44
+ label: "when",
45
+ params: { from: "since", to: "until" },
46
+ },
47
+ ],
48
+ (key) => key,
49
+ );
50
+ expect(resolved).toEqual([
51
+ { field: "occurredAt", label: "when", fromParam: "since", toParam: "until" },
52
+ ]);
53
+ });
54
+
55
+ test("stays out of the option-dropdown facets (no `filters` entry)", () => {
56
+ expect(resolveProjectionFacetSpecs(auditFacets, (key) => key, "audit")).toEqual([]);
57
+ });
58
+
59
+ test("reads both bounds out of the shared f.<field> URL namespace", () => {
60
+ expect(
61
+ readDateRange(
62
+ { "createdAt.from": ["2020-06-10"], "createdAt.to": ["2020-06-14"] },
63
+ "createdAt",
64
+ ),
65
+ ).toEqual({ from: "2020-06-10", to: "2020-06-14" });
66
+ expect(readDateRange({}, "createdAt")).toEqual({ from: "", to: "" });
67
+ });
68
+ });
69
+
70
+ describe("dateRange facet → query params", () => {
71
+ test("both bounds travel under the declared param names", () => {
72
+ expect(payload({ "createdAt.from": ["2020-06-10"], "createdAt.to": ["2020-06-14"] })).toEqual({
73
+ from: "2020-06-09T22:00:00Z",
74
+ to: "2020-06-14T21:59:59.999999999Z",
75
+ });
76
+ });
77
+
78
+ test("open interval: only `from` set sends only that param", () => {
79
+ expect(payload({ "createdAt.from": ["2020-06-10"] })).toEqual({ from: "2020-06-09T22:00:00Z" });
80
+ });
81
+
82
+ test("open interval: only `to` set sends only that param", () => {
83
+ expect(payload({ "createdAt.to": ["2020-06-14"] })).toEqual({
84
+ to: "2020-06-14T21:59:59.999999999Z",
85
+ });
86
+ });
87
+
88
+ test("no bounds set sends nothing", () => {
89
+ expect(payload({})).toEqual({});
90
+ });
91
+
92
+ test("a half-typed or hand-crafted URL value is dropped, not sent", () => {
93
+ expect(payload({ "createdAt.from": ["2020-06"], "createdAt.to": ["nonsense"] })).toEqual({});
94
+ });
95
+
96
+ test("bounds follow the viewer's zone", () => {
97
+ expect(payload({ "createdAt.from": ["2020-06-10"] }, "UTC")).toEqual({
98
+ from: "2020-06-10T00:00:00Z",
99
+ });
100
+ });
101
+
102
+ test("DST: the day the clock jumps forward is still bounded by its own edges", () => {
103
+ // 2020-03-29 is 23h long in Vienna — a naive +24h end-of-day would spill
104
+ // an hour into the 30th.
105
+ expect(payload({ "createdAt.from": ["2020-03-29"], "createdAt.to": ["2020-03-29"] })).toEqual({
106
+ from: "2020-03-28T23:00:00Z",
107
+ to: "2020-03-29T21:59:59.999999999Z",
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("dateRange facet timezone edge (issue #3104)", () => {
113
+ test("an event at 23:59 of the `to` day falls inside the range", () => {
114
+ const { from, to } = payload({
115
+ "createdAt.from": ["2020-06-14"],
116
+ "createdAt.to": ["2020-06-14"],
117
+ });
118
+ if (from === undefined || to === undefined) throw new Error("expected both bounds");
119
+ const lateEvent = Temporal.ZonedDateTime.from(`2020-06-14T23:59:00[${VIENNA}]`).toInstant();
120
+ expect(Temporal.Instant.compare(lateEvent, Temporal.Instant.from(from))).toBeGreaterThan(0);
121
+ // The handler filters with `lte`, so the last instant of the day counts.
122
+ expect(Temporal.Instant.compare(lateEvent, Temporal.Instant.from(to))).toBeLessThanOrEqual(0);
123
+ });
124
+
125
+ test("the next day's first moment falls outside", () => {
126
+ const { to } = payload({ "createdAt.to": ["2020-06-14"] });
127
+ if (to === undefined) throw new Error("expected a `to` bound");
128
+ const nextDay = Temporal.ZonedDateTime.from(`2020-06-15T00:00:00[${VIENNA}]`).toInstant();
129
+ expect(Temporal.Instant.compare(nextDay, Temporal.Instant.from(to))).toBeGreaterThan(0);
130
+ });
131
+ });
132
+
133
+ describe("clampDateRange keeps from <= to", () => {
134
+ test("a `from` past the current `to` pushes `to` along", () => {
135
+ expect(clampDateRange({ from: "2020-06-01", to: "2020-06-10" }, "from", "2020-06-20")).toEqual({
136
+ from: "2020-06-20",
137
+ to: "2020-06-20",
138
+ });
139
+ });
140
+
141
+ test("a `to` before the current `from` pulls `from` along", () => {
142
+ expect(clampDateRange({ from: "2020-06-10", to: "2020-06-20" }, "to", "2020-06-01")).toEqual({
143
+ from: "2020-06-01",
144
+ to: "2020-06-01",
145
+ });
146
+ });
147
+
148
+ test("a valid bound leaves the other alone", () => {
149
+ expect(clampDateRange({ from: "2020-06-01", to: "2020-06-20" }, "from", "2020-06-05")).toEqual({
150
+ from: "2020-06-05",
151
+ to: "2020-06-20",
152
+ });
153
+ });
154
+
155
+ test("clearing a bound never drags the other one with it", () => {
156
+ expect(clampDateRange({ from: "2020-06-10", to: "2020-06-20" }, "from", "")).toEqual({
157
+ from: "",
158
+ to: "2020-06-20",
159
+ });
160
+ expect(clampDateRange({ from: "2020-06-10", to: "2020-06-20" }, "to", "")).toEqual({
161
+ from: "2020-06-10",
162
+ to: "",
163
+ });
164
+ });
165
+ });
@@ -106,10 +106,11 @@ function renderScreen(
106
106
  schema: FeatureSchema,
107
107
  qn: string,
108
108
  dispatcher: Dispatcher = stubDispatcher(),
109
+ timeZone = "UTC",
109
110
  ): void {
110
111
  render(
111
112
  <LocaleProvider
112
- resolver={createStaticLocaleResolver({ locale: "de-DE" })}
113
+ resolver={createStaticLocaleResolver({ locale: "de-DE", timeZone })}
113
114
  fallbackBundles={[kumikoDefaultTranslations]}
114
115
  >
115
116
  <DispatcherProvider dispatcher={dispatcher}>
@@ -426,3 +427,92 @@ describe("projectionList filter + facets (fw#2224)", () => {
426
427
  });
427
428
  });
428
429
  });
430
+
431
+ // fw#3104: a dateRange facet is the first facet that does NOT travel in
432
+ // payload.filters — its two bounds go out as the top-level params the screen
433
+ // declares. A screen may declare it as its only facet, so the wiring must not
434
+ // hang off the option-facet path.
435
+ describe("projectionList dateRange facet (fw#3104)", () => {
436
+ const dateRangeScreen: ProjectionListScreenDefinition = {
437
+ id: "event-list",
438
+ type: "projectionList",
439
+ query: "ledger:query:event:list",
440
+ columns: ["createdAt"],
441
+ facets: [
442
+ {
443
+ field: "createdAt",
444
+ type: "dateRange",
445
+ label: "When",
446
+ params: { from: "from", to: "to" },
447
+ },
448
+ ],
449
+ };
450
+ const schema: FeatureSchema = {
451
+ featureName: "ledger",
452
+ entities: {},
453
+ screens: [dateRangeScreen],
454
+ } as FeatureSchema;
455
+
456
+ test("reaches the DataTable as a dateRange facet, not as a filter dropdown", async () => {
457
+ queryCalls = [];
458
+ capturedProps = undefined;
459
+ renderScreen(schema, "ledger:screen:event-list");
460
+
461
+ await waitFor(() => expect(capturedProps).toBeDefined());
462
+ const props = getCapturedProps();
463
+ if (props === undefined) throw new Error("DataTable was not rendered");
464
+ expect(props.dateRangeFacets).toEqual([
465
+ { field: "createdAt", label: "When", from: "", to: "" },
466
+ ]);
467
+ expect(props.filterFacets).toBeUndefined();
468
+ // Reset still has to be reachable on a dateRange-only screen.
469
+ expect(props.onFilterReset).toBeDefined();
470
+ expect(queryCalls[0]?.payload).not.toHaveProperty("from");
471
+ });
472
+
473
+ test("picking a bound sends it as the declared query param, in the viewer's zone", async () => {
474
+ queryCalls = [];
475
+ capturedProps = undefined;
476
+ renderScreen(schema, "ledger:screen:event-list", stubDispatcher(), "Europe/Vienna");
477
+
478
+ await waitFor(() => expect(capturedProps).toBeDefined());
479
+ const before = queryCalls.length;
480
+ await act(async () => {
481
+ getCapturedProps()?.onDateRangeChange?.("createdAt", "to", "2020-06-14");
482
+ });
483
+ await waitFor(() => expect(queryCalls.length).toBeGreaterThan(before));
484
+ const payload = queryCalls[queryCalls.length - 1]?.payload;
485
+ // Open interval: only the bound the user picked travels.
486
+ expect(payload).toMatchObject({ to: "2020-06-14T21:59:59.999999999Z" });
487
+ expect(payload).not.toHaveProperty("from");
488
+ expect(payload).not.toHaveProperty("filters");
489
+ expect(getCapturedProps()?.dateRangeFacets).toEqual([
490
+ { field: "createdAt", label: "When", from: "", to: "2020-06-14" },
491
+ ]);
492
+ });
493
+
494
+ test("a `from` past the current `to` is clamped before it can reach the handler's refine", async () => {
495
+ queryCalls = [];
496
+ capturedProps = undefined;
497
+ renderScreen(schema, "ledger:screen:event-list", stubDispatcher(), "Europe/Vienna");
498
+
499
+ await waitFor(() => expect(capturedProps).toBeDefined());
500
+ await act(async () => {
501
+ getCapturedProps()?.onDateRangeChange?.("createdAt", "to", "2020-06-14");
502
+ });
503
+ await act(async () => {
504
+ getCapturedProps()?.onDateRangeChange?.("createdAt", "from", "2020-06-20");
505
+ });
506
+ await waitFor(() =>
507
+ expect(getCapturedProps()?.dateRangeFacets).toEqual([
508
+ { field: "createdAt", label: "When", from: "2020-06-20", to: "2020-06-20" },
509
+ ]),
510
+ );
511
+ const payload = queryCalls[queryCalls.length - 1]?.payload as Record<string, string>;
512
+ const from = payload["from"];
513
+ const to = payload["to"];
514
+ expect(from).toBeString();
515
+ expect(to).toBeString();
516
+ expect((from ?? "") <= (to ?? "")).toBe(true);
517
+ });
518
+ });
@@ -18,6 +18,8 @@ import { KumikoScreen } from "../kumiko-screen";
18
18
  import type { NavApi } from "../nav";
19
19
  import { NavProvider } from "../nav";
20
20
 
21
+ const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
22
+
21
23
  const noop = (): ReactNode => null;
22
24
  const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
23
25
  // passChildren would drop testId — ReadOnlyReferenceValue/readOnlyDisplayText
@@ -55,7 +57,7 @@ function stubDispatcher(): Dispatcher {
55
57
  if (type === "sessions:query:session:detail") {
56
58
  return {
57
59
  isSuccess: true,
58
- data: { id: "sess-1", userId: "u-1", ip: "10.0.0.1" },
60
+ data: { id: "sess-1", userId: "u-1", actedBy: SYSTEM_USER_ID, ip: "10.0.0.1" },
59
61
  };
60
62
  }
61
63
  if (type === "user:query:user:list") {
@@ -84,7 +86,13 @@ const detailScreen: ProjectionDetailScreenDefinition = {
84
86
  layout: {
85
87
  sections: [
86
88
  {
87
- fields: [{ field: "userId", refEntity: "user:user", refLabelField: "displayName" }, "ip"],
89
+ fields: [
90
+ { field: "userId", refEntity: "user:user", refLabelField: "displayName" },
91
+ // Mirrors the audit-log detail actor field (fw#3103) — a system
92
+ // write has no read_users row for the bulk lookup to find.
93
+ { field: "actedBy", refEntity: "user:user", refLabelField: "displayName" },
94
+ "ip",
95
+ ],
88
96
  },
89
97
  ],
90
98
  },
@@ -137,6 +145,14 @@ describe("projectionDetail reference field resolves labels (fw#2662)", () => {
137
145
  });
138
146
  });
139
147
 
148
+ test("SYSTEM_USER_ID resolves to the system label instead of the raw id (fw#3103)", async () => {
149
+ const { getByTestId } = renderDetailScreen();
150
+
151
+ await waitFor(() => {
152
+ expect(getByTestId("field-value-actedBy").textContent).toBe("System");
153
+ });
154
+ });
155
+
140
156
  test("non-regression: a field without refEntity metadata renders its raw value unchanged", async () => {
141
157
  const { getByTestId } = renderDetailScreen();
142
158
 
@@ -6,7 +6,10 @@
6
6
  // text back off the reference column's injected runtime renderer.
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
9
+ import type {
10
+ ListColumnSpec,
11
+ ProjectionListScreenDefinition,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
10
13
  import type { Dispatcher, RuntimeRenderer } from "@cosmicdrift/kumiko-headless";
11
14
  import { render, waitFor } from "@testing-library/react";
12
15
  import type { ComponentType, ReactNode } from "react";
@@ -21,6 +24,9 @@ import { NavProvider } from "../nav";
21
24
 
22
25
  const SYSTEM_TENANT_ID = "00000000-0000-4000-8000-000000000000";
23
26
  const REAL_TENANT_ID = "11111111-1111-4111-8111-111111111111";
27
+ const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
28
+ const REAL_USER_ID = "22222222-2222-4222-8222-222222222222";
29
+ const DELETED_USER_ID = "33333333-3333-4333-8333-333333333333";
24
30
 
25
31
  let capturedProps: DataTableProps | undefined;
26
32
  const captureDataTable: ComponentType<DataTableProps> = (props) => {
@@ -61,8 +67,9 @@ function stubDispatcher(): Dispatcher {
61
67
  isSuccess: true,
62
68
  data: {
63
69
  rows: [
64
- { id: "row-1", tenantId: REAL_TENANT_ID },
65
- { id: "row-2", tenantId: SYSTEM_TENANT_ID },
70
+ { id: "row-1", tenantId: REAL_TENANT_ID, createdBy: REAL_USER_ID },
71
+ { id: "row-2", tenantId: SYSTEM_TENANT_ID, createdBy: SYSTEM_USER_ID },
72
+ { id: "row-3", tenantId: REAL_TENANT_ID, createdBy: DELETED_USER_ID },
66
73
  ],
67
74
  nextCursor: null,
68
75
  },
@@ -74,6 +81,12 @@ function stubDispatcher(): Dispatcher {
74
81
  data: { rows: [{ id: REAL_TENANT_ID, name: "Acme Inc" }], nextCursor: null },
75
82
  };
76
83
  }
84
+ if (type === "user:query:user:list") {
85
+ return {
86
+ isSuccess: true,
87
+ data: { rows: [{ id: REAL_USER_ID, displayName: "Ada Lovelace" }], nextCursor: null },
88
+ };
89
+ }
77
90
  return { isSuccess: true, data: { rows: [], nextCursor: null } };
78
91
  }) as unknown as Dispatcher["query"],
79
92
  batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
@@ -95,6 +108,21 @@ function buildSchema(screen: ProjectionListScreenDefinition): FeatureSchema {
95
108
  } as FeatureSchema;
96
109
  }
97
110
 
111
+ const TENANT_COLUMN: ListColumnSpec = {
112
+ field: "tenantId",
113
+ label: "delivery.log.col.tenantId",
114
+ refEntity: "tenant:tenant",
115
+ refLabelField: "name",
116
+ };
117
+
118
+ // Mirrors the audit-log actor column (fw#3103).
119
+ const ACTOR_COLUMN: ListColumnSpec = {
120
+ field: "createdBy",
121
+ label: "audit.log.col.actor",
122
+ refEntity: "user:user",
123
+ refLabelField: "displayName",
124
+ };
125
+
98
126
  const staticNav: NavApi = {
99
127
  route: { screenId: "delivery:screen:log" },
100
128
  navigate: () => {},
@@ -104,19 +132,12 @@ const staticNav: NavApi = {
104
132
  setSearchParams: () => {},
105
133
  };
106
134
 
107
- function renderLogScreen(): void {
135
+ function renderLogScreen(column: ListColumnSpec = TENANT_COLUMN): void {
108
136
  const screen: ProjectionListScreenDefinition = {
109
137
  id: "log",
110
138
  type: "projectionList",
111
139
  query: "delivery:query:log:list",
112
- columns: [
113
- {
114
- field: "tenantId",
115
- label: "delivery.log.col.tenantId",
116
- refEntity: "tenant:tenant",
117
- refLabelField: "name",
118
- },
119
- ],
140
+ columns: [column],
120
141
  };
121
142
  render(
122
143
  <LocaleProvider
@@ -134,8 +155,8 @@ function renderLogScreen(): void {
134
155
  );
135
156
  }
136
157
 
137
- function referenceRenderer(): RuntimeRenderer {
138
- const col = getCapturedProps()?.columns.find((c) => c.field === "tenantId");
158
+ function referenceRenderer(field = "tenantId"): RuntimeRenderer {
159
+ const col = getCapturedProps()?.columns.find((c) => c.field === field);
139
160
  if (typeof col?.renderer !== "function") throw new Error("reference column has no renderer yet");
140
161
  return col.renderer as RuntimeRenderer;
141
162
  }
@@ -159,3 +180,35 @@ describe("projectionList reference column resolves labels (fw#2662)", () => {
159
180
  });
160
181
  });
161
182
  });
183
+
184
+ describe("actor column resolves display names (fw#3103)", () => {
185
+ const actorCell = (id: string): unknown => referenceRenderer("createdBy")(id, { createdBy: id });
186
+
187
+ test("a real user id resolves to the display name", async () => {
188
+ capturedProps = undefined;
189
+ renderLogScreen(ACTOR_COLUMN);
190
+
191
+ await waitFor(() => {
192
+ expect(actorCell(REAL_USER_ID)).toBe("Ada Lovelace");
193
+ });
194
+ });
195
+
196
+ test("SYSTEM_USER_ID resolves to the system label", async () => {
197
+ capturedProps = undefined;
198
+ renderLogScreen(ACTOR_COLUMN);
199
+
200
+ await waitFor(() => {
201
+ expect(actorCell(SYSTEM_USER_ID)).toBe("System");
202
+ });
203
+ });
204
+
205
+ test("an unresolvable actor (deleted user) falls back to the raw id without throwing", async () => {
206
+ capturedProps = undefined;
207
+ renderLogScreen(ACTOR_COLUMN);
208
+
209
+ await waitFor(() => {
210
+ expect(actorCell(REAL_USER_ID)).toBe("Ada Lovelace");
211
+ });
212
+ expect(actorCell(DELETED_USER_ID)).toBe(DELETED_USER_ID);
213
+ });
214
+ });
@@ -50,8 +50,9 @@ import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-cont
50
50
  import { useUserRoles } from "../context/user-roles-context";
51
51
  import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
52
52
  import { type UseQueryResult, useQuery } from "../hooks/use-query";
53
- import { useTranslation } from "../i18n";
53
+ import { useOptionalTimeZone, useTranslation } from "../i18n";
54
54
  import {
55
+ type DataTableDateRangeFacet,
55
56
  type DataTableFacet,
56
57
  type DataTableRowAction,
57
58
  statusToneForValue,
@@ -66,10 +67,14 @@ import type { FeatureSchema } from "./feature-schema";
66
67
  import { buildFormSchema } from "./form-schema";
67
68
  import { layoutFieldNames } from "./layout-fields";
68
69
  import {
70
+ buildDateRangePayload,
69
71
  buildFilterFacets,
70
72
  buildFilterPayload,
73
+ clampDateRange,
71
74
  mergeReferenceFacetOptions,
72
75
  type ResolvedFacetSpec,
76
+ readDateRange,
77
+ resolveDateRangeFacets,
73
78
  resolveProjectionFacetSpecs,
74
79
  } from "./list-facets";
75
80
  import { type NavApi, type ScreenTarget, useInitialValuesHandoff, useNav } from "./nav";
@@ -101,7 +106,7 @@ import {
101
106
  runProjectionRowNavigate,
102
107
  stringifyNavParams,
103
108
  } from "./row-actions";
104
- import { findEditScreenFor, screenAccessAllows } from "./screen-access";
109
+ import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access";
105
110
  import { SecretMintBody } from "./secret-mint-body";
106
111
  import { SecretsEditBody } from "./secrets-edit-body";
107
112
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
@@ -2410,6 +2415,19 @@ function ProjectionListBody({
2410
2415
  [urlState.filters, facetSpecs],
2411
2416
  );
2412
2417
 
2418
+ // dateRange facets (fw#3104) bypass `filters` entirely — their two bounds
2419
+ // become the top-level query params the facet declares, resolved from a
2420
+ // calendar date to an instant in the viewer's zone.
2421
+ const timeZone = useOptionalTimeZone();
2422
+ const dateRangeSpecs = useMemo(
2423
+ () => resolveDateRangeFacets(screen.facets, effectiveTranslate),
2424
+ [screen.facets, effectiveTranslate],
2425
+ );
2426
+ const dateRangePayload = useMemo(
2427
+ () => buildDateRangePayload(dateRangeSpecs, urlState.filters, timeZone),
2428
+ [dateRangeSpecs, urlState.filters, timeZone],
2429
+ );
2430
+
2413
2431
  const queryPayload = useMemo(() => {
2414
2432
  const payload = buildListQueryPayload({
2415
2433
  limit,
@@ -2430,6 +2448,7 @@ function ProjectionListBody({
2430
2448
  if (screen.facets !== undefined && filterPayload.length > 0) {
2431
2449
  payload["filters"] = filterPayload;
2432
2450
  }
2451
+ Object.assign(payload, dateRangePayload);
2433
2452
  return payload;
2434
2453
  }, [
2435
2454
  limit,
@@ -2440,6 +2459,7 @@ function ProjectionListBody({
2440
2459
  screen.filter,
2441
2460
  screen.facets,
2442
2461
  filterPayload,
2462
+ dateRangePayload,
2443
2463
  ]);
2444
2464
 
2445
2465
  const rowsQuery = useQuery<PagedRows>(screen.query, queryPayload, { live: true });
@@ -2448,6 +2468,24 @@ function ProjectionListBody({
2448
2468
  () => buildFilterFacets(resolvedFacetSpecs),
2449
2469
  [resolvedFacetSpecs],
2450
2470
  );
2471
+ const dateRangeFacets = useMemo<DataTableDateRangeFacet[]>(
2472
+ () =>
2473
+ dateRangeSpecs.map((spec) => ({
2474
+ field: spec.field,
2475
+ label: spec.label,
2476
+ ...readDateRange(urlState.filters, spec.field),
2477
+ })),
2478
+ [dateRangeSpecs, urlState.filters],
2479
+ );
2480
+ const handleDateRangeChange = useCallback(
2481
+ (field: string, bound: "from" | "to", value: string) => {
2482
+ urlState.setDateRange(
2483
+ field,
2484
+ clampDateRange(readDateRange(urlState.filters, field), bound, value),
2485
+ );
2486
+ },
2487
+ [urlState.setDateRange, urlState.filters],
2488
+ );
2451
2489
 
2452
2490
  // Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
2453
2491
  // resolution happens in the NavApi impl. Unlike there: NO row["id"]
@@ -2583,8 +2621,14 @@ function ProjectionListBody({
2583
2621
  {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
2584
2622
  {...(filterFacets.length > 0 && {
2585
2623
  filterFacets,
2586
- filterValues: urlState.filters,
2587
2624
  onFilterChange: urlState.setFilter,
2625
+ })}
2626
+ {...(dateRangeFacets.length > 0 && {
2627
+ dateRangeFacets,
2628
+ onDateRangeChange: handleDateRangeChange,
2629
+ })}
2630
+ {...((filterFacets.length > 0 || dateRangeFacets.length > 0) && {
2631
+ filterValues: urlState.filters,
2588
2632
  onFilterReset: urlState.clearFilters,
2589
2633
  })}
2590
2634
  />
@@ -3184,7 +3228,7 @@ function ProjectionDetailBody({
3184
3228
  const testId = `kumiko-screen-projection-detail-metric-${field}`;
3185
3229
  const navigate = metricNavigateSpec(metric);
3186
3230
  const onPress =
3187
- navigate !== undefined
3231
+ navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
3188
3232
  ? () => runMetricNavigate(nav, navigate, record, host)
3189
3233
  : undefined;
3190
3234
  return Metric !== undefined ? (
@@ -4,6 +4,7 @@
4
4
  import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
5
5
  import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
  import type { Translate } from "@cosmicdrift/kumiko-headless";
7
+ import { Temporal } from "temporal-polyfill";
7
8
  import type { DataTableFacet } from "../primitives";
8
9
 
9
10
  // One resolved facet, independent of where the type info came from — an
@@ -94,40 +95,165 @@ export function resolveProjectionFacetSpecs(
94
95
  ): ResolvedFacetSpec[] {
95
96
  if (facets === undefined) return [];
96
97
  const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
97
- return facets.map((facet): ResolvedFacetSpec => {
98
+ // dateRange carries no option list and no `filters` entry — it resolves
99
+ // through resolveDateRangeFacets into its own toolbar control instead.
100
+ return facets.flatMap((facet): ResolvedFacetSpec[] => {
101
+ if (facet.type === "dateRange") return [];
98
102
  if (facet.type === "select") {
99
- return {
100
- field: facet.field,
101
- type: "select",
102
- label: tr(facet.label),
103
- options: facet.options.map((opt) => ({
104
- value: opt.value,
105
- label: tr(opt.label),
106
- })),
107
- };
103
+ return [
104
+ {
105
+ field: facet.field,
106
+ type: "select",
107
+ label: tr(facet.label),
108
+ options: facet.options.map((opt) => ({
109
+ value: opt.value,
110
+ label: tr(opt.label),
111
+ })),
112
+ },
113
+ ];
108
114
  }
109
115
  if (facet.type === "boolean") {
110
- return {
111
- field: facet.field,
112
- type: "boolean",
113
- label: tr(facet.label),
114
- options: [
115
- { value: "true", label: tr(facet.trueLabel) },
116
- { value: "false", label: tr(facet.falseLabel) },
117
- ],
118
- };
116
+ return [
117
+ {
118
+ field: facet.field,
119
+ type: "boolean",
120
+ label: tr(facet.label),
121
+ options: [
122
+ { value: "true", label: tr(facet.trueLabel) },
123
+ { value: "false", label: tr(facet.falseLabel) },
124
+ ],
125
+ },
126
+ ];
119
127
  }
120
128
  const target = parseRefTarget(facet.entity, featureName);
121
- return {
122
- field: facet.field,
123
- type: "reference",
124
- label: tr(facet.label),
125
- options: [],
126
- reference: {
127
- refEntity: target.entityName,
128
- refFeature: target.featureName,
129
- labelField: facet.labelField ?? "id",
129
+ return [
130
+ {
131
+ field: facet.field,
132
+ type: "reference",
133
+ label: tr(facet.label),
134
+ options: [],
135
+ reference: {
136
+ refEntity: target.entityName,
137
+ refFeature: target.featureName,
138
+ labelField: facet.labelField ?? "id",
139
+ },
130
140
  },
131
- };
141
+ ];
132
142
  });
133
143
  }
144
+
145
+ // --- dateRange facet (fw#3104) ---
146
+
147
+ // A resolved dateRange facet. Kept out of ResolvedFacetSpec because it shares
148
+ // nothing with the option-dropdown facets: no options, no `filters` entry —
149
+ // the two bounds travel as named top-level query params.
150
+ export type ResolvedDateRangeFacet = {
151
+ readonly field: string;
152
+ readonly label: string;
153
+ readonly fromParam: string;
154
+ readonly toParam: string;
155
+ };
156
+
157
+ export type DateRangeBound = "from" | "to";
158
+
159
+ export type DateRangeValue = { readonly from: string; readonly to: string };
160
+
161
+ export function resolveDateRangeFacets(
162
+ facets: readonly ListFacetSpec[] | undefined,
163
+ translate: Translate,
164
+ ): ResolvedDateRangeFacet[] {
165
+ if (facets === undefined) return [];
166
+ return facets.flatMap((facet): ResolvedDateRangeFacet[] =>
167
+ facet.type === "dateRange"
168
+ ? [
169
+ {
170
+ field: facet.field,
171
+ label: isFacetI18nKey(facet.label) ? translate(facet.label) : facet.label,
172
+ fromParam: facet.params.from,
173
+ toParam: facet.params.to,
174
+ },
175
+ ]
176
+ : [],
177
+ );
178
+ }
179
+
180
+ // The bounds live in the same `<screenId>.f.<key>` URL namespace the option
181
+ // facets use, so setFilter's page-reset and clearFilters cover them for free.
182
+ // buildFilterPayload drops them again: a dotted key resolves to no facet
183
+ // type, which is its "unknown field" case.
184
+ export function dateRangeUrlField(field: string, bound: DateRangeBound): string {
185
+ return `${field}.${bound}`;
186
+ }
187
+
188
+ export function readDateRange(
189
+ urlFilters: Readonly<Record<string, readonly string[]>>,
190
+ field: string,
191
+ ): DateRangeValue {
192
+ return {
193
+ from: urlFilters[dateRangeUrlField(field, "from")]?.[0] ?? "",
194
+ to: urlFilters[dateRangeUrlField(field, "to")]?.[0] ?? "",
195
+ };
196
+ }
197
+
198
+ // Keeps from <= to by pushing the opposite bound along, so an inverted range
199
+ // can't reach the server and trip a handler's `from <= to` refine (a 422 the
200
+ // user would see as a broken list, not as a correction).
201
+ export function clampDateRange(
202
+ current: DateRangeValue,
203
+ bound: DateRangeBound,
204
+ value: string,
205
+ ): DateRangeValue {
206
+ if (bound === "from") {
207
+ const to = current.to !== "" && value !== "" && value > current.to ? value : current.to;
208
+ return { from: value, to };
209
+ }
210
+ const from = current.from !== "" && value !== "" && value < current.from ? value : current.from;
211
+ return { from, to: value };
212
+ }
213
+
214
+ // A calendar date covers a whole day in the viewer's zone: "from the 14th"
215
+ // starts at that day's first instant, "to the 14th" includes everything up to
216
+ // its last. Going through the next day's start keeps both ends right across a
217
+ // DST boundary, where the day is not 24h long.
218
+ function dayBoundInstant(date: string, bound: DateRangeBound, timeZone: string): string | null {
219
+ let plainDate: Temporal.PlainDate;
220
+ try {
221
+ plainDate = Temporal.PlainDate.from(date);
222
+ } catch {
223
+ // Half-typed or hand-crafted URL value — drop the bound rather than send
224
+ // something the handler's ISO-datetime schema would reject.
225
+ return null;
226
+ }
227
+ const start = plainDate.toZonedDateTime(timeZone).toInstant();
228
+ return bound === "from"
229
+ ? start.toString()
230
+ : plainDate
231
+ .add({ days: 1 })
232
+ .toZonedDateTime(timeZone)
233
+ .toInstant()
234
+ .subtract({
235
+ nanoseconds: 1,
236
+ })
237
+ .toString();
238
+ }
239
+
240
+ export function buildDateRangePayload(
241
+ specs: readonly ResolvedDateRangeFacet[],
242
+ urlFilters: Readonly<Record<string, readonly string[]>>,
243
+ timeZone: string,
244
+ ): Record<string, string> {
245
+ const out: Record<string, string> = {};
246
+ for (const spec of specs) {
247
+ const range = readDateRange(urlFilters, spec.field);
248
+ for (const [bound, param] of [
249
+ ["from", spec.fromParam],
250
+ ["to", spec.toParam],
251
+ ] as const) {
252
+ const value = range[bound];
253
+ if (value === "") continue;
254
+ const instant = dayBoundInstant(value, bound, timeZone);
255
+ if (instant !== null) out[param] = instant;
256
+ }
257
+ }
258
+ return out;
259
+ }
@@ -4,6 +4,7 @@ import type {
4
4
  FeatureSchema,
5
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
  import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import { lastSegment } from "./qn";
7
8
 
8
9
  // Minimal role-gate for the screen-render path (#1203 — nav filtering via
9
10
  // filterByAccess in workspace-shell.tsx hid role-gated screens from the
@@ -23,6 +24,39 @@ export function screenAccessAllows(
23
24
  return access.roles.some((role) => userRoles.includes(role));
24
25
  }
25
26
 
27
+ // A navigate target (metric click) declares no access rule of its own — the
28
+ // destination screen already does, so the jump is offered exactly when the
29
+ // user may open where it lands (solon#424). No second copy of the rule to
30
+ // drift from the screen's own.
31
+ export function navigateTargetAllows(
32
+ navigate: { readonly screen?: string; readonly entity?: string },
33
+ appFeatures: readonly FeatureSchema[],
34
+ userRoles: readonly string[] | undefined,
35
+ ): boolean {
36
+ const { screen, entity } = navigate;
37
+ // Neither set — stays on the current screen (tab switch), already allowed.
38
+ if (screen === undefined && entity === undefined) return true;
39
+ // KumikoScreen rendered outside createKumikoApp has no screen registry to
40
+ // resolve against; gating on an empty one would hide every jump, not the
41
+ // forbidden ones.
42
+ if (appFeatures.length === 0) return true;
43
+ for (const feature of appFeatures) {
44
+ for (const candidate of feature.screens) {
45
+ const isTarget =
46
+ entity !== undefined
47
+ ? candidate.detailFor === entity
48
+ : lastSegment(candidate.id) === screen;
49
+ // First match IS the destination: short ids are globally unique
50
+ // (validateScreenShortIdCollisions) and both the router and
51
+ // resolveTarget take the first hit.
52
+ if (isTarget) return screenAccessAllows(candidate.access, userRoles);
53
+ }
54
+ }
55
+ // Unresolvable target — the boot-validator doesn't check metric navigate
56
+ // targets, so this is a typo'd dead jump. Don't offer it.
57
+ return false;
58
+ }
59
+
26
60
  // Searches all mounted features, and access is part of the predicate so a
27
61
  // role-gated first match can't hide an accessible second one.
28
62
  export function findEditScreenFor(
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.292.0",
4
+ "type": "fix",
5
+ "title": "A projectionDetail metric is only clickable when its navigate target is reachable",
6
+ "detail": "`MetricNavigate` declares no access rule of its own, so a metric could jump to a screen the current user is not allowed to open — the destination rendered its access-denied banner, or the target's query rejected the request server-side, after the user had already clicked. The metric now derives its gate from the destination: `navigate.screen` is resolved by short id, `navigate.entity` through the screen declaring `detailFor`, and the screen's own `access` decides whether the metric renders clickable at all. No second copy of the rule on the metric, so it cannot drift from the screen's. A `navigate` with only `tab` set stays on the record and is unaffected.\nTwo consequences worth knowing. A navigate target that resolves to no registered screen is treated as denied — the boot-validator does not check metric navigate targets, so a typo'd target used to render a clickable metric that went nowhere and now renders a plain one. And the gate needs the app-wide screen registry `createKumikoApp` provides; a `KumikoScreen` rendered outside it sees no registry and every metric stays clickable, exactly as before."
7
+ },
2
8
  {
3
9
  "version": "0.291.0",
4
10
  "type": "improvement",
@@ -18,6 +18,7 @@ import type { ListSort } from "../hooks/use-list-url-state";
18
18
  import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
19
19
  import { useTranslation } from "../i18n";
20
20
  import {
21
+ type DataTableDateRangeFacet,
21
22
  type DataTableFacet,
22
23
  type DataTableRowAction,
23
24
  type DataTableRowActionMode,
@@ -110,6 +111,10 @@ export type RenderListProps = {
110
111
  readonly onFilterChange?: (field: string, values: readonly string[]) => void;
111
112
  /** Reset aller aktiven Facets. */
112
113
  readonly onFilterReset?: () => void;
114
+ /** Time-range filters (fw#3104) — forwarded to the DataTable. */
115
+ readonly dateRangeFacets?: readonly DataTableDateRangeFacet[];
116
+ /** One bound changed (set URL state + refetch). */
117
+ readonly onDateRangeChange?: (field: string, bound: "from" | "to", value: string) => void;
113
118
  /** Forwarded to `DataTableProps.chromeless` — drops the table's own card
114
119
  * frame for a host that already provides one (relatedList in a tabs-mode
115
120
  * section, fw#2722). Default false. */
@@ -175,6 +180,8 @@ export function RenderList(props: RenderListProps): ReactNode {
175
180
  filterValues,
176
181
  onFilterChange,
177
182
  onFilterReset,
183
+ dateRangeFacets,
184
+ onDateRangeChange,
178
185
  chromeless,
179
186
  scrollBody,
180
187
  screenPadding,
@@ -392,6 +399,8 @@ export function RenderList(props: RenderListProps): ReactNode {
392
399
  {...(filterValues !== undefined && { filterValues })}
393
400
  {...(onFilterChange !== undefined && { onFilterChange })}
394
401
  {...(onFilterReset !== undefined && { onFilterReset })}
402
+ {...(dateRangeFacets !== undefined && { dateRangeFacets })}
403
+ {...(onDateRangeChange !== undefined && { onDateRangeChange })}
395
404
  {...(chromeless !== undefined && { chromeless })}
396
405
  {...(scrollBody !== undefined && { scrollBody })}
397
406
  {...(screenPadding !== undefined && { screenPadding })}
@@ -48,6 +48,14 @@ export type ListUrlStateApi = ListUrlState & {
48
48
  readonly setFilter: (field: string, values: readonly string[]) => void;
49
49
  /** Löscht alle aktiven Faceted-Filter (Reset-Button). */
50
50
  readonly clearFilters: () => void;
51
+ /** Sets both bounds of a dateRange facet in ONE params update — two
52
+ * setFilter calls would overwrite each other, since the second still sees
53
+ * the old searchParams. An empty string clears that bound. Resets the page
54
+ * like setFilter. */
55
+ readonly setDateRange: (
56
+ field: string,
57
+ next: { readonly from: string; readonly to: string },
58
+ ) => void;
51
59
  };
52
60
 
53
61
  // `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
@@ -142,11 +150,22 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
142
150
  [nav, screenId],
143
151
  );
144
152
 
153
+ const setDateRange = useCallback(
154
+ (field: string, next: { readonly from: string; readonly to: string }) => {
155
+ nav.setSearchParams({
156
+ [key(screenId, `f.${field}.from`)]: next.from === "" ? null : next.from,
157
+ [key(screenId, `f.${field}.to`)]: next.to === "" ? null : next.to,
158
+ [key(screenId, "page")]: null,
159
+ });
160
+ },
161
+ [nav, screenId],
162
+ );
163
+
145
164
  const clearFilters = useCallback(() => {
146
165
  const updates: Record<string, string | null> = { [key(screenId, "page")]: null };
147
166
  for (const field of Object.keys(filters)) updates[key(screenId, `f.${field}`)] = null;
148
167
  nav.setSearchParams(updates);
149
168
  }, [nav, screenId, filters]);
150
169
 
151
- return { sort, q, page, filters, setSort, setQ, setPage, setFilter, clearFilters };
170
+ return { sort, q, page, filters, setSort, setQ, setPage, setFilter, clearFilters, setDateRange };
152
171
  }
@@ -67,6 +67,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
67
67
  "kumiko.list.related-list-truncated":
68
68
  "Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
69
69
  "kumiko.reference.system-tenant": "System",
70
+ "kumiko.reference.system-user": "System",
70
71
 
71
72
  "kumiko.pager.status": "{from}–{to} of {total}",
72
73
  "kumiko.pager.previousPage": "Previous page",
package/src/i18n.tsx CHANGED
@@ -205,6 +205,17 @@ export function useOptionalLocale(): string | undefined {
205
205
  return ctx === undefined ? undefined : ctx.resolver.locale();
206
206
  }
207
207
 
208
+ /** Non-throwing time-zone read — "UTC" outside LocaleProvider, matching
209
+ * createStaticLocaleResolver's own default. */
210
+ export function useOptionalTimeZone(): string {
211
+ const ctx = useContext(LocaleContext);
212
+ return useSyncExternalStore(
213
+ (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
214
+ () => (ctx ? ctx.resolver.timeZone() : "UTC"),
215
+ () => (ctx ? ctx.resolver.timeZone() : "UTC"),
216
+ );
217
+ }
218
+
208
219
  /** Non-throwing translate — undefined outside LocaleProvider. */
209
220
  export function useOptionalTranslation():
210
221
  | ((key: string, params?: Readonly<Record<string, unknown>>) => string)
package/src/index.ts CHANGED
@@ -64,6 +64,13 @@ export type {
64
64
  export { isAppSchema, toAppSchema } from "./app/feature-schema";
65
65
  export type { KumikoScreenProps } from "./app/kumiko-screen";
66
66
  export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen";
67
+ export type { DateRangeBound, DateRangeValue, ResolvedDateRangeFacet } from "./app/list-facets";
68
+ export {
69
+ buildDateRangePayload,
70
+ clampDateRange,
71
+ readDateRange,
72
+ resolveDateRangeFacets,
73
+ } from "./app/list-facets";
67
74
  export type {
68
75
  NavApi,
69
76
  NavProviderProps,
@@ -188,6 +195,7 @@ export type {
188
195
  CardProps,
189
196
  CardSlots,
190
197
  CorePrimitives,
198
+ DataTableDateRangeFacet,
191
199
  DataTableFacet,
192
200
  DataTableProps,
193
201
  DataTableRowAction,
@@ -572,6 +572,18 @@ export type DataTableFacet = {
572
572
  readonly options: readonly { readonly value: string; readonly label: string }[];
573
573
  };
574
574
 
575
+ // Time-range filter slot in the toolbar (fw#3104): two date inputs
576
+ // (from/to) instead of an options dropdown. Values are `YYYY-MM-DD` or ""
577
+ // (bound open) — the caller converts them into query params.
578
+ export type DataTableDateRangeFacet = {
579
+ /** The facet's namespace (its date field) — a key, not a filter value. */
580
+ readonly field: string;
581
+ /** Translated label ("When"). */
582
+ readonly label: string;
583
+ readonly from: string;
584
+ readonly to: string;
585
+ };
586
+
575
587
  export type DataTableProps = {
576
588
  readonly columns: readonly ListColumnViewModel[];
577
589
  readonly rows: readonly ListRowViewModel[];
@@ -622,6 +634,10 @@ export type DataTableProps = {
622
634
  /** Reset-Button: löscht alle aktiven Facets. Renderer zeigt ihn nur
623
635
  * wenn mindestens ein Facet aktiv ist. */
624
636
  readonly onFilterReset?: () => void;
637
+ /** Time-range filters beside the facet dropdowns (fw#3104). */
638
+ readonly dateRangeFacets?: readonly DataTableDateRangeFacet[];
639
+ /** One bound changed ("" clears it) → caller writes URL state + refetch. */
640
+ readonly onDateRangeChange?: (field: string, bound: "from" | "to", value: string) => void;
625
641
  /** Pagination-State + Callback. Wenn gesetzt, rendert der Renderer
626
642
  * einen Pager unter der Tabelle (Web: Footer-Bar mit ← 1 ... N →).
627
643
  * total/limit/page sind 1-basiert für die UI; Server-Translation