@remit/web-client 0.0.73 → 0.0.75

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": "@remit/web-client",
3
- "version": "0.0.73",
3
+ "version": "0.0.75",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -18,6 +18,7 @@ import { makeMailbox } from "../../../test-support/fixtures";
18
18
  import {
19
19
  type HttpCall,
20
20
  type HttpMock,
21
+ httpError,
21
22
  mockFetch,
22
23
  } from "../../../test-support/http";
23
24
  import { OrganizeRuleEditor } from "./OrganizeRuleEditor";
@@ -276,7 +277,7 @@ describe("OrganizeRuleEditor — the previewed set equals the applied set", () =
276
277
  });
277
278
 
278
279
  describe("OrganizeRuleEditor — scope mapping", () => {
279
- it("saves a standing filter carrying the sender fallback clauses", async () => {
280
+ it("saves a standing filter, then back-applies it over the existing mail", async () => {
280
281
  const dom = mount({
281
282
  semanticUnavailable: true,
282
283
  senders: ["npm@github.com"],
@@ -288,7 +289,14 @@ describe("OrganizeRuleEditor — scope mapping", () => {
288
289
 
289
290
  dom.type(dom.byLabel("Rule name"), "GitHub");
290
291
  dom.click(primaryButton(dom, "Save rule"));
291
- await dom.flush();
292
+
293
+ // The filter is created, then the same predicate is back-applied over the
294
+ // mail already in the mailbox — not only the mail that arrives next.
295
+ for (let attempt = 0; attempt < 40; attempt += 1) {
296
+ await dom.flush();
297
+ if (/moved/.test(dom.text())) break;
298
+ await dom.wait(5);
299
+ }
292
300
 
293
301
  const created = (http?.calls ?? []).filter((call) =>
294
302
  call.path.endsWith("/filters"),
@@ -300,7 +308,25 @@ describe("OrganizeRuleEditor — scope mapping", () => {
300
308
  assert.deepEqual(created[0].body?.literalClauses, [
301
309
  { field: "From", value: "npm@github.com" },
302
310
  ]);
303
- assert.match(dom.text(), /Filter saved/);
311
+
312
+ // The back-apply carries exactly the filter's predicate and runs after it.
313
+ const backApply = (http?.calls ?? []).filter(
314
+ (call) => call.path.endsWith("/organize") && call.method === "POST",
315
+ );
316
+ assert.equal(backApply.length, 1);
317
+ assert.equal(backApply[0].body?.matchOperator, "Or");
318
+ assert.deepEqual(backApply[0].body?.literalClauses, [
319
+ { field: "From", value: "npm@github.com" },
320
+ ]);
321
+ assert.ok(
322
+ (http?.calls ?? []).indexOf(created[0]) <
323
+ (http?.calls ?? []).indexOf(backApply[0]),
324
+ "the filter is created before the back-apply runs",
325
+ );
326
+
327
+ // The flow lands on the back-apply's done summary, not a bare saved screen.
328
+ assert.match(dom.text(), /Done/);
329
+ assert.doesNotMatch(dom.text(), /Filter saved/);
304
330
  });
305
331
 
306
332
  it("saves an until-a-date filter with the derived expiry", async () => {
@@ -323,6 +349,120 @@ describe("OrganizeRuleEditor — scope mapping", () => {
323
349
  });
324
350
  });
325
351
 
352
+ describe("OrganizeRuleEditor — back-apply gating and failure", () => {
353
+ async function flushUntil(
354
+ dom: DomHarness,
355
+ holds: () => boolean,
356
+ ): Promise<void> {
357
+ for (let attempt = 0; attempt < 40; attempt += 1) {
358
+ await dom.flush();
359
+ if (holds()) return;
360
+ await dom.wait(5);
361
+ }
362
+ }
363
+
364
+ it("skips the back-apply for a HasWords rule, saving it for incoming mail only", async () => {
365
+ const dom = mount(
366
+ {
367
+ semanticUnavailable: true,
368
+ senders: [],
369
+ seedScope: "standing",
370
+ seedMailboxId: "mbx-archive",
371
+ },
372
+ previewCounts([5]),
373
+ );
374
+ await dom.flush();
375
+
376
+ // A body-content clause: the vector-free back-apply can't evaluate it.
377
+ dom.click(primaryButton(dom, "Add clause"));
378
+ dom.select(dom.byLabel("Clause field"), "HasWords");
379
+ dom.type(dom.byLabel("Clause value"), "invoice");
380
+ dom.click(primaryButton(dom, "Add"));
381
+ await settlePreview(dom);
382
+
383
+ dom.type(dom.byLabel("Rule name"), "Invoices");
384
+ dom.click(primaryButton(dom, "Save rule"));
385
+ await dom.flush();
386
+ await dom.flush();
387
+
388
+ // The filter is created and carries the clause.
389
+ const created = (http?.calls ?? []).filter((call) =>
390
+ call.path.endsWith("/filters"),
391
+ );
392
+ assert.equal(created.length, 1);
393
+ assert.deepEqual(created[0].body?.literalClauses, [
394
+ { field: "HasWords", value: "invoice" },
395
+ ]);
396
+
397
+ // No back-apply is attempted — the guard is never reached.
398
+ const backApply = (http?.calls ?? []).filter(
399
+ (call) => call.path.endsWith("/organize") && call.method === "POST",
400
+ );
401
+ assert.equal(backApply.length, 0);
402
+ assert.match(dom.text(), /Filter saved/);
403
+ });
404
+
405
+ it("surfaces a failed back-apply start distinctly, and retries it", async () => {
406
+ const failStart: Responder = (call) => {
407
+ if (call.path.endsWith("/organize/preview")) {
408
+ return { matchedCount: 2, messageIds: [] };
409
+ }
410
+ if (call.path.endsWith("/filters")) {
411
+ return { filterId: "filter-1", name: "R", scope: "Standing" };
412
+ }
413
+ if (call.path.endsWith("/organize") && call.method === "POST") {
414
+ return httpError(500);
415
+ }
416
+ return {};
417
+ };
418
+ const dom = mount(
419
+ {
420
+ semanticUnavailable: true,
421
+ senders: ["npm@github.com"],
422
+ seedScope: "standing",
423
+ seedMailboxId: "mbx-archive",
424
+ seedCount: 128,
425
+ },
426
+ failStart,
427
+ );
428
+ await dom.flush();
429
+
430
+ dom.type(dom.byLabel("Rule name"), "GitHub");
431
+ dom.click(primaryButton(dom, "Save rule"));
432
+
433
+ await flushUntil(dom, () => /Move existing mail/.test(dom.text()));
434
+
435
+ // The filter saved and the start was attempted once, then failed.
436
+ const created = (http?.calls ?? []).filter((call) =>
437
+ call.path.endsWith("/filters"),
438
+ );
439
+ assert.equal(created.length, 1);
440
+ const firstStart = (http?.calls ?? []).filter(
441
+ (call) => call.path.endsWith("/organize") && call.method === "POST",
442
+ );
443
+ assert.equal(firstStart.length, 1);
444
+
445
+ // The failure is honest: the rule saved, the move is what to retry — never
446
+ // a bare "Filter saved" that hides it.
447
+ assert.match(dom.text(), /Rule saved/);
448
+ assert.doesNotMatch(dom.text(), /Filter saved/);
449
+
450
+ // Retrying re-issues the back-apply start.
451
+ dom.click(primaryButton(dom, "Move existing mail"));
452
+ await flushUntil(
453
+ dom,
454
+ () =>
455
+ (http?.calls ?? []).filter(
456
+ (call) => call.path.endsWith("/organize") && call.method === "POST",
457
+ ).length >= 2,
458
+ );
459
+ const afterRetry = (http?.calls ?? []).filter(
460
+ (call) => call.path.endsWith("/organize") && call.method === "POST",
461
+ );
462
+ assert.ok(afterRetry.length >= 2);
463
+ });
464
+ });
465
+
326
466
  describe("OrganizeRuleEditor — back-apply progress copy (#250 honesty)", () => {
327
467
  async function startJob(dom: DomHarness): Promise<void> {
328
468
  dom.select(dom.byLabel("Destination folder"), "mbx-archive");
@@ -6,7 +6,7 @@ import {
6
6
  type RuleScope,
7
7
  } from "@remit/ui";
8
8
  import { useQuery } from "@tanstack/react-query";
9
- import { useMemo, useState } from "react";
9
+ import { useEffect, useMemo, useRef, useState } from "react";
10
10
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
11
11
  import { useCreateFilter } from "@/hooks/useFilters";
12
12
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
@@ -14,6 +14,10 @@ import { useRuleEditorState } from "@/hooks/useRuleEditorState";
14
14
  import { useRulePreview } from "@/hooks/useRulePreview";
15
15
  import { getMailboxDisplayName } from "@/lib/folder-roles";
16
16
  import { buildMoveTargets } from "@/lib/move-targets";
17
+ import {
18
+ canBackApplyDraft,
19
+ type OrganizeDraft,
20
+ } from "@/lib/organize/organize-model";
17
21
  import {
18
22
  buildInitialRule,
19
23
  rulePredicate,
@@ -21,6 +25,7 @@ import {
21
25
  SUPPORTED_CLAUSE_FIELDS,
22
26
  } from "@/lib/organize/rule-model";
23
27
  import {
28
+ BackApplyError,
24
29
  CommitError,
25
30
  FilterSaved,
26
31
  JobProgress,
@@ -50,7 +55,9 @@ interface OrganizeRuleEditorProps {
50
55
  * The Organize surface as the chip editor (RFC 038 D1). The rule is rendered and
51
56
  * edited over the existing preview/apply endpoints: clause chips, a
52
57
  * match-operator toggle, a move action, and a scope that maps one-time apply to
53
- * a back-apply job and standing/until to a `Filter`. The count is live and the
58
+ * a back-apply job and standing/until to a `Filter`. Creating a filter also runs
59
+ * the back-apply once, so the rule reaches the mail already in the mailbox and
60
+ * not only the mail that arrives next. The count is live and the
54
61
  * commit gate holds apply until it settles, so the set the editor shows is the
55
62
  * set a commit acts on. Rendered inside the desktop dialog and the mobile sheet
56
63
  * alike, so the two cannot drift.
@@ -107,12 +114,26 @@ export function OrganizeRuleEditor({
107
114
  const createFilter = useCreateFilter(accountId);
108
115
  const { createFolder } = useCreateMailbox(accountId);
109
116
 
117
+ // Creating a filter also moves the mail that already matches, not only the
118
+ // mail that arrives next: the same retroactive back-apply the one-time scope
119
+ // runs. The filter is created first so the rule is live before the pass, then
120
+ // the pass runs over the existing corpus. `backApplyDraft` is the predicate
121
+ // to run once the create succeeds, and is undefined when the rule cannot be
122
+ // back-applied (a `HasWords` clause the vector-free pass can't evaluate — the
123
+ // filter still saves and applies to incoming mail). It is kept, not cleared,
124
+ // so a failed start can be retried; `backApplyStarted` guards the one-shot
125
+ // auto-start against re-firing on re-render.
126
+ const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
127
+ const backApplyStarted = useRef(false);
128
+
110
129
  const commit = () => {
111
130
  const draft = ruleToDraft(rule, anchorMessageId);
112
131
  if (rule.scope === "once") {
113
132
  organizeJob.start(draft);
114
133
  return;
115
134
  }
135
+ backApplyStarted.current = false;
136
+ setBackApplyDraft(canBackApplyDraft(draft) ? draft : undefined);
116
137
  createFilter.createFilter(
117
138
  draft,
118
139
  rule.scope === "standing" ? "standing" : "temporary",
@@ -120,6 +141,17 @@ export function OrganizeRuleEditor({
120
141
  );
121
142
  };
122
143
 
144
+ useEffect(() => {
145
+ if (!backApplyDraft || backApplyStarted.current || !createFilter.isSuccess)
146
+ return;
147
+ backApplyStarted.current = true;
148
+ organizeJob.start(backApplyDraft);
149
+ }, [backApplyDraft, createFilter.isSuccess, organizeJob.start]);
150
+
151
+ const retryBackApply = () => {
152
+ if (backApplyDraft) organizeJob.start(backApplyDraft);
153
+ };
154
+
123
155
  if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
124
156
  return (
125
157
  <JobProgress
@@ -135,11 +167,26 @@ export function OrganizeRuleEditor({
135
167
  );
136
168
  }
137
169
 
170
+ // The filter saved, but the back-apply's own request failed (a 5xx or dropped
171
+ // connection on start, or a failed retry) — no job id was ever assigned, so no
172
+ // JobProgress state holds. Surface it distinctly instead of falling through to
173
+ // "Filter saved" and swallowing it: the rule is live, the move is what to
174
+ // retry.
175
+ if (createFilter.isSuccess && organizeJob.isError) {
176
+ return <BackApplyError onRetry={retryBackApply} onClose={onClose} />;
177
+ }
178
+
138
179
  if (createFilter.isPending) {
139
180
  return <SavingState />;
140
181
  }
141
182
 
142
183
  if (createFilter.isSuccess) {
184
+ // The filter is saved; the back-apply is about to start (the effect hands
185
+ // off to the job on the next tick). Keep the saving state until it takes
186
+ // over so the success screen never flashes between them. When the rule
187
+ // can't be back-applied, `backApplyDraft` is undefined and this is the
188
+ // terminal state — saved, applying to incoming mail only.
189
+ if (backApplyDraft) return <SavingState />;
143
190
  return <FilterSaved onClose={onClose} />;
144
191
  }
145
192
 
@@ -0,0 +1,147 @@
1
+ import { BottomSheet } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import type { ReactNode } from "react";
4
+ import {
5
+ BackApplyError,
6
+ CommitError,
7
+ FilterSaved,
8
+ JobProgress,
9
+ SavingState,
10
+ } from "./rule-editor-states";
11
+
12
+ /**
13
+ * `Flows/Smart Organize/Rule editor states` — the non-editing states the rule
14
+ * editor lands in after a commit, rendered from the real components the Organize
15
+ * surface mounts. Creating a standing filter now runs the retroactive back-apply,
16
+ * so a standing save flows Saving → back-apply in flight → done, the same job
17
+ * states the one-time apply reaches. Every state here is what ships, so the flow
18
+ * cannot drift from the story.
19
+ */
20
+
21
+ function SheetStage({ children }: { children: ReactNode }) {
22
+ return (
23
+ <div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[520px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
24
+ <BottomSheet open onClose={() => undefined}>
25
+ {children}
26
+ </BottomSheet>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ const meta: Meta = {
32
+ title: "Flows/Smart Organize/Rule editor states",
33
+ parameters: { layout: "fullscreen" },
34
+ };
35
+ export default meta;
36
+
37
+ type Story = StoryObj;
38
+
39
+ /** Saving the filter — held while the create request is in flight. */
40
+ export const Saving: Story = {
41
+ render: () => (
42
+ <SheetStage>
43
+ <SavingState />
44
+ </SheetStage>
45
+ ),
46
+ };
47
+
48
+ /**
49
+ * The back-apply running after a standing filter is created — the pass that
50
+ * reaches the mail already in the mailbox, not only the mail that arrives next.
51
+ */
52
+ export const BackApplyInFlight: Story = {
53
+ name: "Back-apply in flight",
54
+ render: () => (
55
+ <SheetStage>
56
+ <JobProgress
57
+ progress={{
58
+ state: "Running",
59
+ matchedCount: 24,
60
+ appliedCount: 0,
61
+ failedCount: 0,
62
+ errorMessage: "",
63
+ }}
64
+ isDone={false}
65
+ runningLabel="Organizing mail from these senders…"
66
+ onClose={() => undefined}
67
+ />
68
+ </SheetStage>
69
+ ),
70
+ };
71
+
72
+ /** The back-apply finished — the count moved is stated plainly. */
73
+ export const BackApplyDone: Story = {
74
+ name: "Back-apply done",
75
+ render: () => (
76
+ <SheetStage>
77
+ <JobProgress
78
+ progress={{
79
+ state: "Complete",
80
+ matchedCount: 24,
81
+ appliedCount: 24,
82
+ failedCount: 0,
83
+ errorMessage: "",
84
+ }}
85
+ isDone
86
+ runningLabel="Organizing mail from these senders…"
87
+ onClose={() => undefined}
88
+ />
89
+ </SheetStage>
90
+ ),
91
+ };
92
+
93
+ /** The back-apply failed — the rule is still saved, the move is what did not run. */
94
+ export const BackApplyFailed: Story = {
95
+ name: "Back-apply failed",
96
+ render: () => (
97
+ <SheetStage>
98
+ <JobProgress
99
+ progress={{
100
+ state: "Failed",
101
+ matchedCount: 24,
102
+ appliedCount: 6,
103
+ failedCount: 18,
104
+ errorMessage: "Could not reach the mail server. Please try again.",
105
+ }}
106
+ isDone
107
+ runningLabel="Organizing mail from these senders…"
108
+ onClose={() => undefined}
109
+ />
110
+ </SheetStage>
111
+ ),
112
+ };
113
+
114
+ /**
115
+ * The filter saved, but the back-apply's own request never got off the ground
116
+ * (a 5xx or dropped connection on start). The rule is live for new mail; moving
117
+ * the existing mail is what failed, and it is offered again — never swallowed
118
+ * behind a plain "saved".
119
+ */
120
+ export const BackApplyStartFailed: Story = {
121
+ name: "Back-apply start failed",
122
+ render: () => (
123
+ <SheetStage>
124
+ <BackApplyError onRetry={() => undefined} onClose={() => undefined} />
125
+ </SheetStage>
126
+ ),
127
+ };
128
+
129
+ /** The filter saved with nothing to back-apply — the plain confirmation. */
130
+ export const FilterSavedState: Story = {
131
+ name: "Filter saved",
132
+ render: () => (
133
+ <SheetStage>
134
+ <FilterSaved onClose={() => undefined} />
135
+ </SheetStage>
136
+ ),
137
+ };
138
+
139
+ /** The create failed — retry or dismiss. */
140
+ export const CommitFailed: Story = {
141
+ name: "Commit failed",
142
+ render: () => (
143
+ <SheetStage>
144
+ <CommitError onRetry={() => undefined} onClose={() => undefined} />
145
+ </SheetStage>
146
+ ),
147
+ };
@@ -88,6 +88,33 @@ export function FilterSaved({ onClose }: { onClose: () => void }) {
88
88
  );
89
89
  }
90
90
 
91
+ export function BackApplyError({
92
+ onRetry,
93
+ onClose,
94
+ }: {
95
+ onRetry: () => void;
96
+ onClose: () => void;
97
+ }) {
98
+ return (
99
+ <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
100
+ <CheckCircle2 className="size-8 text-positive" />
101
+ <p className="text-sm font-medium text-fg">Rule saved</p>
102
+ <p className="max-w-xs text-xs text-fg-muted">
103
+ New mail follows it automatically. Moving the mail already in your
104
+ mailbox didn't run — try again?
105
+ </p>
106
+ <div className="mt-2 flex gap-2">
107
+ <Button variant="primary" onClick={onRetry}>
108
+ Move existing mail
109
+ </Button>
110
+ <Button variant="ghost" onClick={onClose}>
111
+ Not now
112
+ </Button>
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+
91
118
  export function CommitError({
92
119
  onRetry,
93
120
  onClose,
@@ -60,12 +60,17 @@ const filterFixture = (
60
60
 
61
61
  type Responder = (call: HttpCall) => unknown;
62
62
 
63
- /** Fields whose presence in a patch bumps `ruleChangedAt` (RFC 034 Decision 3.2). */
64
- const PREDICATE_OR_ACTION = [
63
+ /**
64
+ * Fields whose presence in a patch bumps `ruleChangedAt` (RFC 034 Decision
65
+ * 3.2, reader #266).
66
+ */
67
+ const RULE_ASSERTION_FIELDS = [
65
68
  "matchOperator",
66
69
  "literalClauses",
67
70
  "actionLabelId",
68
71
  "actionMailboxId",
72
+ "scope",
73
+ "expiresAt",
69
74
  ];
70
75
 
71
76
  /**
@@ -84,7 +89,7 @@ const backend = (
84
89
  }
85
90
  if (call.path.endsWith("/filters/f-1") && call.method === "PATCH") {
86
91
  const body = call.body ?? {};
87
- const bumped = PREDICATE_OR_ACTION.some((field) => field in body);
92
+ const bumped = RULE_ASSERTION_FIELDS.some((field) => field in body);
88
93
  return {
89
94
  ...filter,
90
95
  ...body,
@@ -254,12 +259,12 @@ describe("FilterEditor — degraded semantic filter (RFC 038 D4)", () => {
254
259
  dom.query('[aria-label="Remove the similar-mail widen"]'),
255
260
  null,
256
261
  );
257
- assert.match(dom.text(), /set when a filter is created/i);
262
+ assert.match(dom.text(), /similar-mail match is fixed to the message/i);
258
263
  });
259
264
  });
260
265
 
261
- describe("FilterEditor — scope and expiry are read-only (reader #266)", () => {
262
- it("shows an until-a-date filter's scope statically, with no scope or date control", async () => {
266
+ describe("FilterEditor — scope and expiry are editable (reader #266)", () => {
267
+ it("keeps the scope toggle and date input live, minus the once option", async () => {
263
268
  const dom = mount(
264
269
  filterFixture({
265
270
  scope: "Temporary",
@@ -268,14 +273,56 @@ describe("FilterEditor — scope and expiry are read-only (reader #266)", () =>
268
273
  );
269
274
  await settlePreview(dom);
270
275
 
271
- // The live scope segmented control and the date input are gone — a
272
- // scope/date change cannot be persisted, so it is never offered.
273
- assert.equal(dom.query('input[name="rule-scope"]'), null);
274
- assert.equal(dom.query('[aria-label="Expiry date"]'), null);
275
- assert.match(dom.text(), /Until 2027-09-01/);
276
- assert.match(dom.text(), /set when a filter is created/i);
276
+ assert.ok(dom.query('input[name="rule-scope"]'));
277
+ assert.ok(dom.byLabel("Expiry date"));
278
+ assert.doesNotMatch(dom.text(), /Just once/);
277
279
 
278
- // The name stays editable.
280
+ // The name stays editable too.
279
281
  assert.ok(dom.byLabel("Rule name"));
280
282
  });
283
+
284
+ it("moves a standing filter to until-a-date, patches scope and expiresAt, and offers the re-apply", async () => {
285
+ const dom = mount(filterFixture());
286
+ await settlePreview(dom);
287
+
288
+ dom.click(dom.byText("label", "Until a date"));
289
+ dom.type(dom.byLabel("Expiry date"), "2027-09-01");
290
+ await dom.flush();
291
+
292
+ dom.click(primaryButton(dom, "Save until then"));
293
+ await dom.flush();
294
+
295
+ const patch = patchCalls();
296
+ assert.equal(patch.length, 1);
297
+ assert.equal(patch[0].body?.scope, "Temporary");
298
+ assert.match(String(patch[0].body?.expiresAt ?? ""), /^2027-09-01T/);
299
+ // The predicate/action and the name are untouched, so neither travels.
300
+ assert.equal("matchOperator" in (patch[0].body ?? {}), false);
301
+ assert.equal("name" in (patch[0].body ?? {}), false);
302
+
303
+ // A scope/expiry change is a rule reassertion too — it offers the
304
+ // re-apply exactly like a predicate/action change does.
305
+ assert.match(dom.text(), /Move existing mail/);
306
+ });
307
+
308
+ it("moves an until-a-date filter back to standing and clears the expiry", async () => {
309
+ const dom = mount(
310
+ filterFixture({
311
+ scope: "Temporary",
312
+ expiresAt: "2027-09-01T23:59:59+00:00",
313
+ }),
314
+ );
315
+ await settlePreview(dom);
316
+
317
+ dom.click(dom.byText("label", "Keep doing this"));
318
+ await dom.flush();
319
+
320
+ dom.click(primaryButton(dom, "Save rule"));
321
+ await dom.flush();
322
+
323
+ const patch = patchCalls();
324
+ assert.equal(patch.length, 1);
325
+ assert.equal(patch[0].body?.scope, "Standing");
326
+ assert.equal("expiresAt" in (patch[0].body ?? {}), false);
327
+ });
281
328
  });
@@ -8,6 +8,7 @@ import {
8
8
  type FolderOption,
9
9
  type MatchOperator,
10
10
  previewCountSummary,
11
+ type RuleScope,
11
12
  } from "@remit/ui";
12
13
  import { CheckCircle2, Loader2 } from "lucide-react";
13
14
  import { useMemo, useRef, useState } from "react";
@@ -19,6 +20,7 @@ import {
19
20
  buildUpdateFilterInput,
20
21
  filterToRule,
21
22
  ruleChangesPredicateOrAction,
23
+ ruleChangesScopeOrExpiry,
22
24
  } from "@/lib/organize/filter-edit-model";
23
25
  import {
24
26
  normalizeClauseValue,
@@ -43,11 +45,14 @@ interface FilterEditorProps {
43
45
  /**
44
46
  * Editing a standing filter in the same chip editor the Organize surface uses
45
47
  * (RFC 038 D6). The row's persisted rule opens in the editor — clauses, match
46
- * operator, move action, scope, and the semantic anchor as a widen chip. Saving
47
- * a predicate or action change bumps `ruleChangedAt` and offers, never runs, a
48
- * re-back-apply over existing mail; a cosmetic rename does neither (RFC 034
49
- * Decision 3.2). The re-apply carries exactly the previewed predicate and is
50
- * held behind the same settled-count commit gate as creation.
48
+ * operator, move action, scope, expiry, and the semantic anchor as a widen
49
+ * chip. Saving a predicate, action, scope, or expiry change bumps
50
+ * `ruleChangedAt` and offers, never runs, a re-back-apply over existing mail;
51
+ * a cosmetic rename does neither (RFC 034 Decision 3.2, reader #266). The
52
+ * re-apply carries exactly the previewed predicate and is held behind the
53
+ * same settled-count commit gate as creation. The anchor stays fixed at
54
+ * creation regardless — repointing it would silently change what the filter
55
+ * matches, which deserves a new filter instead.
51
56
  */
52
57
  export function FilterEditor({
53
58
  accountId,
@@ -142,14 +147,26 @@ export function FilterEditor({
142
147
  const changeName = (name: string) =>
143
148
  setRule((current) => ({ ...current, name }));
144
149
 
150
+ const changeScope = (scope: RuleScope) =>
151
+ setRule((current) => ({
152
+ ...current,
153
+ scope,
154
+ until: scope === "until" ? current.until : undefined,
155
+ }));
156
+
157
+ const changeUntil = (until: string) =>
158
+ setRule((current) => ({ ...current, until }));
159
+
145
160
  const commit = () => {
146
- const predicateChanged = ruleChangesPredicateOrAction(rule, original);
161
+ const rulesChanged =
162
+ ruleChangesPredicateOrAction(rule, original) ||
163
+ ruleChangesScopeOrExpiry(rule, original);
147
164
  const body = buildUpdateFilterInput(rule, original);
148
165
  if (Object.keys(body).length === 0) {
149
166
  onClose();
150
167
  return;
151
168
  }
152
- setOfferReapply(predicateChanged);
169
+ setOfferReapply(rulesChanged);
153
170
  update.updateFilter(body);
154
171
  };
155
172
 
@@ -188,13 +205,15 @@ export function FilterEditor({
188
205
  rule={rule}
189
206
  folders={folders}
190
207
  preview={preview}
191
- // The update endpoint carries no anchor, so a widen can be neither added
192
- // nor removed here: the "…and similar" add is never offered, and the
193
- // existing chip is display-only (no onRemoveWiden). `semanticUnavailable`
194
- // only drives the chip's inactive styling via `filterToRule`.
208
+ // The update endpoint carries no anchor field at all (reader #266), so a
209
+ // widen can be neither added nor removed here: the "…and similar" add is
210
+ // never offered, and the existing chip is display-only (no
211
+ // onRemoveWiden anchorLocked enforces that regardless).
212
+ // `semanticUnavailable` only drives the chip's inactive styling via
213
+ // `filterToRule`.
195
214
  semanticAvailable={false}
196
215
  clauseFields={SUPPORTED_CLAUSE_FIELDS}
197
- lifecycleLocked
216
+ anchorLocked
198
217
  clauseEdit={clauseEdit}
199
218
  onStartAddClause={startAddClause}
200
219
  onStartEditClause={startEditClause}
@@ -207,6 +226,8 @@ export function FilterEditor({
207
226
  onChangeMove={changeMove}
208
227
  onCreateFolder={createFolder}
209
228
  onChangeName={changeName}
229
+ onChangeScope={changeScope}
230
+ onChangeUntil={changeUntil}
210
231
  onCommit={commit}
211
232
  onCancel={onClose}
212
233
  />
@@ -7,6 +7,7 @@ import {
7
7
  expiresAtToPickedDate,
8
8
  filterToRule,
9
9
  ruleChangesPredicateOrAction,
10
+ ruleChangesScopeOrExpiry,
10
11
  } from "./filter-edit-model";
11
12
 
12
13
  const filter = (
@@ -132,6 +133,48 @@ describe("ruleChangesPredicateOrAction", () => {
132
133
  });
133
134
  });
134
135
 
136
+ describe("ruleChangesScopeOrExpiry (reader #266)", () => {
137
+ const base = filterToRule(filter());
138
+
139
+ it("is false for an identical rule and for a rename only", () => {
140
+ assert.equal(ruleChangesScopeOrExpiry(base, base), false);
141
+ assert.equal(
142
+ ruleChangesScopeOrExpiry({ ...base, name: "New name" }, base),
143
+ false,
144
+ );
145
+ });
146
+
147
+ it("is true when scope moves to until-a-date", () => {
148
+ assert.equal(
149
+ ruleChangesScopeOrExpiry(
150
+ { ...base, scope: "until", until: "2027-01-01" },
151
+ base,
152
+ ),
153
+ true,
154
+ );
155
+ });
156
+
157
+ it("is true when only the until date changes", () => {
158
+ const untilBase = filterToRule(
159
+ filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
160
+ );
161
+ assert.equal(
162
+ ruleChangesScopeOrExpiry(
163
+ { ...untilBase, until: "2027-06-01" },
164
+ untilBase,
165
+ ),
166
+ true,
167
+ );
168
+ });
169
+
170
+ it("is false when moving between standing and once-equivalent scopes with no until set", () => {
171
+ assert.equal(
172
+ ruleChangesScopeOrExpiry({ ...base, scope: "standing" }, base),
173
+ false,
174
+ );
175
+ });
176
+ });
177
+
135
178
  describe("buildUpdateFilterInput", () => {
136
179
  const original = filterToRule(filter());
137
180
 
@@ -172,4 +215,47 @@ describe("buildUpdateFilterInput", () => {
172
215
  it("is empty when nothing changed", () => {
173
216
  assert.deepEqual(buildUpdateFilterInput(original, original), {});
174
217
  });
218
+
219
+ it("sends scope Temporary and a derived expiresAt when moving to until-a-date (reader #266)", () => {
220
+ const changed: FilterRule = {
221
+ ...original,
222
+ scope: "until",
223
+ until: "2027-03-04",
224
+ };
225
+ const body = buildUpdateFilterInput(changed, original);
226
+ assert.equal(body.scope, "Temporary");
227
+ assert.match(body.expiresAt ?? "", /^2027-03-04T/);
228
+ assert.equal("matchOperator" in body, false);
229
+ assert.equal("name" in body, false);
230
+ });
231
+
232
+ it("sends scope Standing with no expiresAt when moving off a Temporary filter", () => {
233
+ const temporaryOriginal = filterToRule(
234
+ filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
235
+ );
236
+ const changed: FilterRule = { ...temporaryOriginal, scope: "standing" };
237
+ const body = buildUpdateFilterInput(changed, temporaryOriginal);
238
+ assert.equal(body.scope, "Standing");
239
+ assert.equal("expiresAt" in body, false);
240
+ });
241
+
242
+ it("sends scope and the new expiresAt when only the date changes", () => {
243
+ const temporaryOriginal = filterToRule(
244
+ filter({ scope: "Temporary", expiresAt: "2027-01-01T23:59:59+00:00" }),
245
+ );
246
+ const changed: FilterRule = { ...temporaryOriginal, until: "2027-06-01" };
247
+ const body = buildUpdateFilterInput(changed, temporaryOriginal);
248
+ assert.equal(body.scope, "Temporary");
249
+ assert.match(body.expiresAt ?? "", /^2027-06-01T/);
250
+ });
251
+
252
+ it("carries no anchor field — the editor never has one to send (reader #266)", () => {
253
+ const changed: FilterRule = {
254
+ ...original,
255
+ scope: "until",
256
+ until: "2027-03-04",
257
+ };
258
+ const body = buildUpdateFilterInput(changed, original);
259
+ assert.equal("anchorMessageId" in body, false);
260
+ });
175
261
  });
@@ -3,6 +3,7 @@ import type {
3
3
  RemitImapUpdateFilterInput,
4
4
  } from "@remit/api-http-client/types.gen.ts";
5
5
  import type { FilterRule, RuleClause } from "@remit/ui";
6
+ import { pickedDateToExpiresAt } from "./filter-status";
6
7
  import { NO_ACTION } from "./organize-model";
7
8
 
8
9
  /**
@@ -79,13 +80,34 @@ export const ruleChangesPredicateOrAction = (
79
80
  original: FilterRule,
80
81
  ): boolean => predicateActionKey(rule) !== predicateActionKey(original);
81
82
 
83
+ const scopeExpiryKey = (rule: FilterRule): string =>
84
+ JSON.stringify({
85
+ scope: rule.scope === "until" ? "until" : "standing",
86
+ until: rule.scope === "until" ? (rule.until ?? "") : "",
87
+ });
88
+
89
+ /**
90
+ * Whether the edited rule changes scope (standing ↔ until-a-date) or the date
91
+ * itself, versus the one it was loaded from (reader #266). Scope and expiry
92
+ * are mutable on an existing filter, unlike the anchor, and a change here
93
+ * bumps `ruleChangedAt` the same way a predicate/action change does — moving a
94
+ * lapsed filter back to Standing (or extending its date) is the same kind of
95
+ * "the user just reasserted this rule" moment, and re-offers the back-apply so
96
+ * mail delivered while the filter sat inactive can be caught up.
97
+ */
98
+ export const ruleChangesScopeOrExpiry = (
99
+ rule: FilterRule,
100
+ original: FilterRule,
101
+ ): boolean => scopeExpiryKey(rule) !== scopeExpiryKey(original);
102
+
82
103
  /**
83
104
  * The PATCH body for an edited filter. A cosmetic rename sends `{ name }` only,
84
- * so the server's `changesPredicateOrAction` guard leaves `ruleChangedAt`
105
+ * so the server's `changesRuleAssertion` guard leaves `ruleChangedAt`
85
106
  * untouched (RFC 034 Decision 3.2). A predicate or action change sends the
86
- * operator, clauses, and move target, which bumps `ruleChangedAt`. Fields the
87
- * editor never touches — the label action, the immutable scope/expiryare
88
- * absent from the patch, so the partial update preserves them.
107
+ * operator, clauses, and move target; a scope or expiry change sends `scope`
108
+ * and, for the `until` scope, `expiresAt` (reader #266)either bumps
109
+ * `ruleChangedAt`. The label action and the anchor are never in the editor's
110
+ * gift, so they never enter the patch; the partial update preserves them.
89
111
  */
90
112
  export const buildUpdateFilterInput = (
91
113
  rule: FilterRule,
@@ -102,5 +124,11 @@ export const buildUpdateFilterInput = (
102
124
  }));
103
125
  body.actionMailboxId = rule.moveMailboxId ?? NO_ACTION;
104
126
  }
127
+ if (ruleChangesScopeOrExpiry(rule, original)) {
128
+ body.scope = rule.scope === "until" ? "Temporary" : "Standing";
129
+ if (rule.scope === "until") {
130
+ body.expiresAt = pickedDateToExpiresAt(rule.until ?? "");
131
+ }
132
+ }
105
133
  return body;
106
134
  };
@@ -60,6 +60,17 @@ export interface OrganizeDraft {
60
60
  export const hasCommittableAction = (draft: OrganizeDraft): boolean =>
61
61
  draft.moveMailboxId !== undefined && draft.moveMailboxId !== NO_ACTION;
62
62
 
63
+ /**
64
+ * Whether the draft's predicate can be back-applied over the existing corpus. A
65
+ * `HasWords` clause matches the full message body, which only the live
66
+ * index-time filter and the vector widen evaluate — the vector-free back-apply
67
+ * projection carries no body and rejects it (`assertNoBodyContentClause`,
68
+ * organize.ts). A rule that carries one is still a valid standing filter for
69
+ * incoming mail, so the back-apply is skipped, not run into that guard.
70
+ */
71
+ export const canBackApplyDraft = (draft: OrganizeDraft): boolean =>
72
+ !draft.literalClauses.some((clause) => clause.field === "HasWords");
73
+
63
74
  /**
64
75
  * Build the read-only preview / back-apply matcher input. The action fields do
65
76
  * not affect which messages match — the preview returns exactly the set a job