@startsimpli/integrations 0.2.7 → 0.2.11

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": "@startsimpli/integrations",
3
- "version": "0.2.7",
3
+ "version": "0.2.11",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -299,3 +299,100 @@ describe('the tenant’s filters are still not honoured, and are still not faked
299
299
  expect(page.count).toBe(FIXTURE_TENANT_DOCUMENTS.length);
300
300
  });
301
301
  });
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // the tracker's read, against the tenant's real filter vocabulary
305
+ // ---------------------------------------------------------------------------
306
+
307
+ describe('listCommitments speaks the tenant data API’s own filter language', () => {
308
+ function client(applied: string[] | undefined) {
309
+ const seen: Array<{ path: string; params: Record<string, unknown> }> = [];
310
+ const api = createCorpusApi({
311
+ http: {
312
+ async get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T> {
313
+ seen.push({ path, params: options?.params ?? {} });
314
+ return {
315
+ count: 0, next: null, previous: null, results: [],
316
+ ...(applied ? { applied_filters: applied, ignored_filters: [] } : {}),
317
+ } as T;
318
+ },
319
+ },
320
+ scope: { orgId: 'tandem' },
321
+ supportedFilters: [],
322
+ });
323
+ return { api, seen };
324
+ }
325
+
326
+ it('sends status, owner, due window and order as attr.* parameters', async () => {
327
+ const { api, seen } = client(['attr.status__in']);
328
+ await api.listCommitments({
329
+ status: ['open', 'blocked'],
330
+ owner: 'Kate',
331
+ dueBefore: '2026-08-10T00:00:00.000Z',
332
+ changedAfter: '2026-08-03T00:00:00.000Z',
333
+ pageSize: 200,
334
+ });
335
+ expect(seen[0].params).toMatchObject({
336
+ type: 'commitment',
337
+ 'attr.status__in': 'open,blocked',
338
+ 'attr.owner_name__icontains': 'Kate',
339
+ 'attr.due_at__lte': '2026-08-10T00:00:00.000Z',
340
+ 'attr.last_seen_at__gte': '2026-08-03T00:00:00.000Z',
341
+ order: 'attr.due_at',
342
+ page_size: 200,
343
+ });
344
+ });
345
+
346
+ it('caps a text filter at the length the server documents', async () => {
347
+ const { api, seen } = client([]);
348
+ await api.listCommitments({ owner: 'x'.repeat(900), search: 'y'.repeat(900) });
349
+ expect(String(seen[0].params['attr.owner_name__icontains'])).toHaveLength(500);
350
+ expect(String(seen[0].params.search)).toHaveLength(500);
351
+ });
352
+
353
+ it('caps page size at the server’s maximum rather than asking for more', async () => {
354
+ const { api, seen } = client([]);
355
+ await api.listCommitments({ pageSize: 5000 });
356
+ expect(seen[0].params.page_size).toBe(200);
357
+ });
358
+
359
+ it('believes the SERVER about what it filtered, not a hardcoded list', async () => {
360
+ // The static `supportedFilters` says nothing is supported. The server says
361
+ // it applied status. The server wins — otherwise the screen keeps apologising
362
+ // for a gap that was closed while it was not looking.
363
+ const { api } = client(['attr.status__in', 'attr.due_at__lte']);
364
+ const page = await api.listCommitments({ status: ['open', 'blocked'], dueBefore: 'x', owner: 'Kate' });
365
+ expect(page.appliedFilters).toEqual(['status', 'dueBefore']);
366
+ // And owner is NOT claimed, because the server did not report it.
367
+ expect(page.appliedFilters).not.toContain('owner');
368
+ });
369
+
370
+ it('believes the server through the CAMELCASE client too', async () => {
371
+ // `@startsimpli/api` converts every response key snake_case -> camelCase, so
372
+ // in a browser the tenant's `applied_filters` arrives as `appliedFilters`.
373
+ // Reading only one spelling put "this workspace's data API did not filter by
374
+ // status" on a deployed screen while the server was demonstrably filtering
375
+ // by status. Same lesson as accepting `document_id` in a citation.
376
+ const seen: Array<Record<string, unknown>> = [];
377
+ const api = createCorpusApi({
378
+ http: {
379
+ async get<T>(_path: string, options?: { params?: Record<string, unknown> }): Promise<T> {
380
+ seen.push(options?.params ?? {});
381
+ return { count: 0, next: null, previous: null, results: [],
382
+ appliedFilters: ['attr.status__in'], ignoredFilters: [] } as T;
383
+ },
384
+ },
385
+ scope: { orgId: 'tandem' },
386
+ supportedFilters: [],
387
+ });
388
+ const page = await api.listCommitments({ status: ['open', 'blocked'] });
389
+ expect(page.appliedFilters).toEqual(['status']);
390
+ expect(seen[0]['attr.status__in']).toBe('open,blocked');
391
+ });
392
+
393
+ it('falls back to the declared list on a server that does not report', async () => {
394
+ const { api } = client(undefined);
395
+ const page = await api.listCommitments({ status: 'open' });
396
+ expect(page.appliedFilters).toEqual([]);
397
+ });
398
+ });
@@ -7,6 +7,7 @@
7
7
  * as though it had one, a filter that did nothing, a row with no traceable
8
8
  * source, a run that closed something and did not say why.
9
9
  */
10
+ import * as React from 'react';
10
11
  import { render, screen, within } from '@testing-library/react';
11
12
  import userEvent from '@testing-library/user-event';
12
13
  import { describe, expect, it, vi } from 'vitest';
@@ -92,6 +93,61 @@ function makeApi(options: {
92
93
  };
93
94
  }
94
95
 
96
+ describe('TrackerPage — it must not loop', () => {
97
+ /**
98
+ * SHIPPED TO PRODUCTION AND CAUGHT BY A HUMAN, not by this suite: 917
99
+ * `entities` requests in ten minutes on the live tenant, the screen visibly
100
+ * flashing. The cause was three lines that each looked reasonable:
101
+ *
102
+ * now = Date.now() a defaulted prop — a NEW value per render
103
+ * useMemo(..., [tab, owner, now]) so a new query object per render
104
+ * useCallback(..., [api, query]) so a new loader per render
105
+ * useEffect(..., [load]) so a fetch per render -> setState -> render
106
+ *
107
+ * A defaulted `Date.now()` can never be a stable dependency. These tests count
108
+ * requests, because that is the only thing that catches this: at 1.5 requests
109
+ * a second the screenshot looks perfect.
110
+ */
111
+ it('fetches ONCE on mount and then stops', async () => {
112
+ let calls = 0;
113
+ const api = makeApi({ onQuery: () => { calls += 1; } });
114
+ render(<TrackerPage api={api} scope={SCOPE} />);
115
+ await screen.findByText('Chase the inventor for comments on 2148');
116
+ await new Promise((resolve) => setTimeout(resolve, 250));
117
+ expect(calls).toBe(1);
118
+ });
119
+
120
+ it('does not loop even when the app hands it a NEW api object every render', async () => {
121
+ // The composer takes an injected capability. A caller that builds it inline
122
+ // is a mistake, but it must not be a runaway one — the identity of an api is
123
+ // not a reason to re-read the same query.
124
+ let calls = 0;
125
+ function Harness() {
126
+ const [, force] = React.useState(0);
127
+ React.useEffect(() => {
128
+ const timer = setInterval(() => force((value) => value + 1), 20);
129
+ return () => clearInterval(timer);
130
+ }, []);
131
+ // Deliberately unmemoized.
132
+ return <TrackerPage api={makeApi({ onQuery: () => { calls += 1; } })} scope={SCOPE} />;
133
+ }
134
+ render(<Harness />);
135
+ await screen.findByText('Chase the inventor for comments on 2148');
136
+ await new Promise((resolve) => setTimeout(resolve, 300));
137
+ expect(calls).toBe(1);
138
+ });
139
+
140
+ it('DOES re-read when the user actually changes something', async () => {
141
+ let calls = 0;
142
+ render(<TrackerPage api={makeApi({ onQuery: () => { calls += 1; } })} scope={SCOPE} now={NOW} />);
143
+ await screen.findByText('Chase the inventor for comments on 2148');
144
+ expect(calls).toBe(1);
145
+ await userEvent.click(screen.getByRole('tab', { name: 'Overdue' }));
146
+ await new Promise((resolve) => setTimeout(resolve, 150));
147
+ expect(calls).toBe(2);
148
+ });
149
+ });
150
+
95
151
  describe('TrackerPage — what is outstanding', () => {
96
152
  it('shows open items and hides closed ones', async () => {
97
153
  render(<TrackerPage api={makeApi()} scope={SCOPE} now={NOW} />);
@@ -116,7 +172,7 @@ describe('TrackerPage — what is outstanding', () => {
116
172
  // The Library's `appliedFilters` contract, owed here about status — a
117
173
  // status filter that silently does nothing breaks the screen's only claim.
118
174
  expect(
119
- await screen.findByText(/does not filter by .*status/i),
175
+ await screen.findByText(/did not filter by .*status/i),
120
176
  ).toBeInTheDocument();
121
177
  });
122
178
 
@@ -129,7 +185,7 @@ describe('TrackerPage — what is outstanding', () => {
129
185
  />,
130
186
  );
131
187
  await screen.findByText('Chase the inventor for comments on 2148');
132
- expect(screen.queryByText(/does not filter by/i)).not.toBeInTheDocument();
188
+ expect(screen.queryByText(/did not filter by/i)).not.toBeInTheDocument();
133
189
  });
134
190
 
135
191
  it('puts an overdue item above one with no due date', async () => {
@@ -41,7 +41,15 @@ export interface TrackerPageProps {
41
41
  scope: CorpusScope;
42
42
  /** Click-through from a mention to the record, normally the Library route. */
43
43
  onOpenDocument?: (documentId: string) => void;
44
- /** Injectable clock, so "overdue" and "this week" are testable. */
44
+ /**
45
+ * Injectable clock, so "overdue" and "this week" are testable.
46
+ *
47
+ * NOT DEFAULTED IN THE SIGNATURE. `now = Date.now()` is a new value on every
48
+ * render, which makes every memo below it new, which makes the load effect
49
+ * fire on every render, which sets state, which renders. That shipped, and it
50
+ * cost 917 requests in ten minutes on a live tenant. When the caller does not
51
+ * supply a clock, one is frozen ONCE for the life of the component.
52
+ */
45
53
  now?: number;
46
54
  classNames?: Partial<Record<TrackerSlot, string>>;
47
55
  }
@@ -79,11 +87,24 @@ export function TrackerPage({
79
87
  api,
80
88
  scope,
81
89
  onOpenDocument,
82
- now = Date.now(),
90
+ now,
83
91
  classNames = {},
84
92
  }: TrackerPageProps) {
85
93
  const cn = { ...DEFAULTS, ...classNames };
86
94
 
95
+ // Frozen once. See the note on the prop.
96
+ const [mountedAt] = React.useState(() => Date.now());
97
+ const clock = now ?? mountedAt;
98
+
99
+ /**
100
+ * The api is an injected CAPABILITY, not data, so its identity is not a reason
101
+ * to re-read the same query — and an app that builds it inline must not turn
102
+ * that mistake into a runaway. Held in a ref and read at call time; a genuine
103
+ * change of workspace re-reads through `scope.orgId` below.
104
+ */
105
+ const apiRef = React.useRef(api);
106
+ apiRef.current = api;
107
+
87
108
  const [tab, setTab] = React.useState<'open' | 'overdue' | 'recent' | 'done'>('open');
88
109
  const [owner, setOwner] = React.useState('');
89
110
  const [rows, setRows] = React.useState<Commitment[]>([]);
@@ -101,18 +122,18 @@ export function TrackerPage({
101
122
  // and the banner alone would be doing all the work.
102
123
  status: tab === 'done' ? 'done' : (['open', 'blocked'] as CommitmentStatus[]),
103
124
  ...(owner.trim() ? { owner: owner.trim() } : {}),
104
- ...(tab === 'recent' ? { changedAfter: new Date(now - WEEK_MS).toISOString() } : {}),
105
- ...(tab === 'overdue' ? { dueBefore: new Date(now).toISOString() } : {}),
125
+ ...(tab === 'recent' ? { changedAfter: new Date(clock - WEEK_MS).toISOString() } : {}),
126
+ ...(tab === 'overdue' ? { dueBefore: new Date(clock).toISOString() } : {}),
106
127
  pageSize: 200,
107
128
  }),
108
- [tab, owner, now],
129
+ [tab, owner, clock],
109
130
  );
110
131
 
111
132
  const load = React.useCallback(async () => {
112
133
  setLoading(true);
113
134
  setError('');
114
135
  try {
115
- const page = await api.listCommitments(query);
136
+ const page = await apiRef.current.listCommitments(query);
116
137
  setRows(page.results);
117
138
  setApplied(page.appliedFilters ?? []);
118
139
  } catch (err) {
@@ -121,35 +142,36 @@ export function TrackerPage({
121
142
  } finally {
122
143
  setLoading(false);
123
144
  }
124
- }, [api, query]);
145
+ }, [query]);
125
146
 
126
- React.useEffect(() => { void load(); }, [load]);
147
+ // Real input only: the query the user asked for, and the workspace they are in.
148
+ React.useEffect(() => { void load(); }, [load, scope.orgId]);
127
149
 
128
150
  /**
129
151
  * The tab, applied in the browser as well as sent to the server.
130
152
  *
131
- * NOT a violation of rule 8 — the query still goes out as parameters and the
132
- * server still pages. This is the honest belt to the server's braces: until bd
133
- * startsim-768w.38.12 teaches the tenant API `?status=`, the API returns
134
- * everything, and a screen that showed closed items under "Outstanding" would
135
- * be lying about the one thing it exists to say. The banner below states that
136
- * the list is not server-filtered, so the user knows the count is over what
137
- * this page could fetch rather than over the whole corpus.
153
+ * NOT a violation of rule 8 — the query goes out as `attr.*` parameters and
154
+ * the server pages and filters. This is the belt to the server's braces: a
155
+ * deployment whose API ignores one of them must not end up rendering closed
156
+ * items under "Outstanding", which is the one thing this screen exists to say.
157
+ * Re-applying a filter the server already applied is a no-op; not re-applying
158
+ * one it ignored is a lie. The banner below fires only when the SERVER itself
159
+ * reports it did not apply something.
138
160
  */
139
161
  const visible = React.useMemo(() => {
140
162
  const byTab = rows.filter((item) => {
141
163
  if (tab === 'done') return item.status === 'done' || item.status === 'dropped';
142
164
  if (item.status !== 'open' && item.status !== 'blocked') return false;
143
- if (tab === 'overdue') return isOverdue(item, now);
144
- if (tab === 'recent') return changedSince(item, now - WEEK_MS);
165
+ if (tab === 'overdue') return isOverdue(item, clock);
166
+ if (tab === 'recent') return changedSince(item, clock - WEEK_MS);
145
167
  return true;
146
168
  });
147
169
  const needle = owner.trim().toLowerCase();
148
170
  const byOwner = needle
149
171
  ? byTab.filter((item) => (item.ownerName ?? '').toLowerCase().includes(needle))
150
172
  : byTab;
151
- return [...byOwner].sort(compareForScreen(now));
152
- }, [rows, tab, owner, now]);
173
+ return [...byOwner].sort(compareForScreen(clock));
174
+ }, [rows, tab, owner, clock]);
153
175
 
154
176
  const unfiltered = (['status', 'owner', 'dueBefore', 'changedAfter'] as const).filter(
155
177
  (key) => Boolean(query[key]) && !applied.includes(key),
@@ -191,7 +213,7 @@ export function TrackerPage({
191
213
  setRunning(true);
192
214
  setError('');
193
215
  try {
194
- setRun(await api.extract!({ scope }));
216
+ setRun(await apiRef.current.extract!({ scope }));
195
217
  await load();
196
218
  } catch (err) {
197
219
  setError(failureMessage(err));
@@ -210,7 +232,7 @@ export function TrackerPage({
210
232
  // status — and it matters more here, because "outstanding" is the
211
233
  // claim the whole screen makes.
212
234
  <p className={cn.warning} role="status">
213
- This workspace’s data API does not filter by {unfiltered.join(', ')} yet, so this list was
235
+ This workspace’s data API did not filter by {unfiltered.join(', ')}, so this list was
214
236
  narrowed in the browser over the records it could fetch. Counts are over that set, not
215
237
  over the whole corpus.
216
238
  </p>
@@ -237,7 +259,7 @@ export function TrackerPage({
237
259
  <CommitmentRow
238
260
  key={item.externalId}
239
261
  item={item}
240
- now={now}
262
+ now={clock}
241
263
  classNames={cn}
242
264
  onOpenDocument={onOpenDocument}
243
265
  />
@@ -114,6 +114,101 @@ interface DrfPage<T> {
114
114
  results: T[];
115
115
  }
116
116
 
117
+ /**
118
+ * What the tenant data API reports back about the query it was handed.
119
+ *
120
+ * `applied_filters` and `ignored_filters` are the whole reason a composer can
121
+ * stop guessing: a screen that narrows its own list has to know whether it is
122
+ * doing so because the server could not, or is duplicating work the server
123
+ * already did. Reported per response, so a capability that lands mid-week is
124
+ * picked up without a client deploy.
125
+ */
126
+ interface ServerFilterReport {
127
+ applied_filters?: string[];
128
+ /**
129
+ * BOTH SPELLINGS, and this is not defensive padding — it is the difference
130
+ * between the banner telling the truth and telling a lie.
131
+ *
132
+ * `@startsimpli/api`'s fetch wrapper converts every response key from
133
+ * snake_case to camelCase by default (`transformKeys`), so the tenant's
134
+ * `applied_filters` reaches this adapter as `appliedFilters` in the browser
135
+ * and as `applied_filters` from a plain server-side `fetch`. Reading only one
136
+ * made the deployed Tracker report "this workspace's data API did not filter
137
+ * by status" while the server was, verifiably, filtering by status. Same
138
+ * lesson as `resolveCitations` accepting `document_id`: the tolerance belongs
139
+ * at the seam.
140
+ */
141
+ appliedFilters?: string[];
142
+ ignored_filters?: string[];
143
+ ignoredFilters?: string[];
144
+ search_backend?: string;
145
+ }
146
+
147
+ /**
148
+ * `CommitmentQuery` -> the tenant's own parameter vocabulary.
149
+ *
150
+ * Attribute filters are `attr.<declared name>` with an optional operator suffix,
151
+ * which is why every field the tracker filters on has to be a DECLARED
152
+ * AttributeDef — an undeclared attribute is invisible to the index and the
153
+ * server reports it under `ignored_filters` rather than silently returning
154
+ * everything.
155
+ */
156
+ const TEXT_FILTER_MAX = 500;
157
+
158
+ function commitmentParams(query: CommitmentQuery, type: string): Record<string, unknown> {
159
+ const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
160
+ const out: Record<string, unknown> = {
161
+ type,
162
+ page: query.page ?? 1,
163
+ ...(query.pageSize ? { page_size: Math.min(query.pageSize, 200) } : {}),
164
+ // Oldest debt first, which is what the screen sorts by anyway — asking the
165
+ // server for it means page one is the right page one.
166
+ order: 'attr.due_at',
167
+ };
168
+ if (statuses.length === 1) out['attr.status'] = statuses[0];
169
+ else if (statuses.length > 1) out['attr.status__in'] = statuses.join(',');
170
+ if (query.subtype) out['attr.subtype'] = query.subtype;
171
+ if (query.owner) out['attr.owner_name__icontains'] = query.owner.slice(0, TEXT_FILTER_MAX);
172
+ if (query.dueBefore) out['attr.due_at__lte'] = query.dueBefore;
173
+ if (query.changedAfter) out['attr.last_seen_at__gte'] = query.changedAfter;
174
+ if (query.search) out.search = query.search.slice(0, TEXT_FILTER_MAX);
175
+ return out;
176
+ }
177
+
178
+ /** Which server parameter carries which `CommitmentQuery` key. */
179
+ const FILTER_PARAMS: Array<[keyof CommitmentQuery, string[]]> = [
180
+ ['status', ['attr.status', 'attr.status__in']],
181
+ ['subtype', ['attr.subtype']],
182
+ ['owner', ['attr.owner_name__icontains']],
183
+ ['dueBefore', ['attr.due_at__lte']],
184
+ ['changedAfter', ['attr.last_seen_at__gte']],
185
+ ['search', ['search']],
186
+ ];
187
+
188
+ /** Which server parameter carries which `DocumentQuery` key. */
189
+ const DOCUMENT_FILTER_PARAMS: Array<[keyof DocumentQuery, string[]]> = [
190
+ ['search', ['search']],
191
+ ['source', ['source', 'attr.source']],
192
+ ['partyId', ['party', 'related_to']],
193
+ ['occurredAfter', ['occurred_after', 'attr.occurred_at__gte']],
194
+ ['occurredBefore', ['occurred_before', 'attr.occurred_at__lte']],
195
+ ];
196
+
197
+ function reportedDocumentFilters(query: DocumentQuery, applied: string[]): string[] {
198
+ const server = new Set(applied);
199
+ return DOCUMENT_FILTER_PARAMS.filter(
200
+ ([key, names]) => Boolean(query[key]) && names.some((name) => server.has(name)),
201
+ ).map(([key]) => key);
202
+ }
203
+
204
+ /** Translate the server's report back into the composer's vocabulary. */
205
+ function reportedFilters(query: CommitmentQuery, applied: string[]): string[] {
206
+ const server = new Set(applied);
207
+ return FILTER_PARAMS.filter(
208
+ ([key, params]) => Boolean(query[key]) && params.some((param) => server.has(param)),
209
+ ).map(([key]) => key);
210
+ }
211
+
117
212
  const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
118
213
 
119
214
  /** Rows written in parallel. Small enough not to look like a burst to the tenant. */
@@ -163,15 +258,20 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & Co
163
258
  query: DocumentQuery,
164
259
  map: (entity: TenantEntity) => T,
165
260
  ): Promise<CorpusPage<T>> {
166
- const response = await http.get<DrfPage<TenantEntity>>(entities, {
261
+ const response = await http.get<DrfPage<TenantEntity> & ServerFilterReport>(entities, {
167
262
  params: params(query, type),
168
263
  });
264
+ // The SERVER's own report wins here too — the Library was reading a
265
+ // hardcoded list while the tenant had learned to filter.
266
+ const reported = response.applied_filters ?? response.appliedFilters;
169
267
  return {
170
268
  count: response.count ?? response.results?.length ?? 0,
171
269
  next: response.next ?? null,
172
270
  previous: response.previous ?? null,
173
271
  results: (response.results ?? []).map(map),
174
- appliedFilters: appliedFilters(query, supportedFilters),
272
+ appliedFilters: reported
273
+ ? reportedDocumentFilters(query, reported)
274
+ : appliedFilters(query, supportedFilters as string[]),
175
275
  };
176
276
  }
177
277
 
@@ -278,28 +378,23 @@ export function createCorpusApi(options: CreateCorpusApiOptions): CorpusApi & Co
278
378
  * than rendering an unfiltered list under a filled-in control.
279
379
  */
280
380
  async listCommitments(query: CommitmentQuery = {}): Promise<CorpusPage<Commitment>> {
281
- const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
282
- const response = await http.get<DrfPage<TenantEntity>>(entities, {
283
- params: {
284
- type: types.commitment,
285
- page: query.page ?? 1,
286
- ...(query.pageSize ? { page_size: query.pageSize } : {}),
287
- ...(statuses.length ? { status: statuses.join(',') } : {}),
288
- ...(query.subtype ? { subtype: query.subtype } : {}),
289
- ...(query.owner ? { owner: query.owner } : {}),
290
- ...(query.dueBefore ? { due_before: query.dueBefore } : {}),
291
- ...(query.changedAfter ? { changed_after: query.changedAfter } : {}),
292
- ...(query.search ? { search: query.search } : {}),
293
- },
294
- });
381
+ const params = commitmentParams(query, types.commitment);
382
+ const response = await http.get<DrfPage<TenantEntity> & ServerFilterReport>(entities, { params });
383
+ const reported = response.applied_filters ?? response.appliedFilters;
295
384
  return {
296
385
  count: response.count ?? response.results?.length ?? 0,
297
386
  next: response.next ?? null,
298
387
  previous: response.previous ?? null,
299
388
  results: (response.results ?? []).map((entity) => toCommitment(entity, scope, sourceLabels)),
300
- appliedFilters: (['status', 'subtype', 'owner', 'dueBefore', 'changedAfter', 'search'] as const)
301
- .filter((key) => Boolean(query[key]))
302
- .filter((key) => (supportedFilters as string[]).includes(key)),
389
+ // THE SERVER'S OWN ANSWER WINS. `applied_filters` is reported by the
390
+ // tenant data API (capabilities.entities.reports_applied_filters), so
391
+ // the screen no longer has to be told out-of-band what the backend can
392
+ // do — and cannot claim a gap that has since been closed. The static
393
+ // `supportedFilters` list is only the fallback for a deployment old
394
+ // enough not to report.
395
+ appliedFilters: reported
396
+ ? reportedFilters(query, reported)
397
+ : appliedFilters(query as DocumentQuery, supportedFilters as string[]),
303
398
  };
304
399
  },
305
400