@remit/web-client 0.0.66 → 0.0.67

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.
@@ -31,12 +31,17 @@ const filter = (
31
31
  ...overrides,
32
32
  });
33
33
 
34
- const render = (filters: RemitImapFilterResponse[]) =>
34
+ const render = (
35
+ filters: RemitImapFilterResponse[],
36
+ semanticUnavailable = false,
37
+ ) =>
35
38
  renderToString(
36
39
  createElement(FiltersList, {
37
40
  filters,
38
41
  mailboxName: (id: string) => (id === "mbx-travel" ? "Travel" : undefined),
42
+ onEdit: () => undefined,
39
43
  onDelete: () => undefined,
44
+ semanticUnavailable,
40
45
  now: NOW,
41
46
  }) as never,
42
47
  );
@@ -54,6 +59,27 @@ describe("FiltersList", () => {
54
59
  assert.match(html, /always/);
55
60
  });
56
61
 
62
+ it("opens the row's rule in the editor", () => {
63
+ const html = render([filter({})]);
64
+ assert.match(html, /Edit filter Travel/);
65
+ });
66
+
67
+ it("shows the widen chip for an anchored filter where the deployment can serve it", () => {
68
+ const html = render([filter({ hasAnchor: true })]);
69
+ assert.match(html, /and anything similar/i);
70
+ });
71
+
72
+ it("lists the widen chip inactive on a deployment that cannot evaluate it (D4)", () => {
73
+ const html = render([filter({ hasAnchor: true })], true);
74
+ assert.match(html, /not available here/i);
75
+ assert.doesNotMatch(html, /and anything similar/i);
76
+ });
77
+
78
+ it("shows no widen chip for a purely literal filter", () => {
79
+ const html = render([filter({ hasAnchor: false })]);
80
+ assert.doesNotMatch(html, /similar/i);
81
+ });
82
+
57
83
  it("keeps an expired temporary filter visible and marks it Expired (RFC 034 Decision 1.2)", () => {
58
84
  const html = render([
59
85
  filter({
@@ -1,5 +1,5 @@
1
1
  import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
2
- import { Badge, Button } from "@remit/ui";
2
+ import { Badge, Button, WidenChip } from "@remit/ui";
3
3
  import { Trash2 } from "lucide-react";
4
4
  import {
5
5
  filterDisplayStatus,
@@ -11,21 +11,34 @@ interface FiltersListProps {
11
11
  filters: RemitImapFilterResponse[];
12
12
  /** Resolve a destination mailbox id to a folder name for display. */
13
13
  mailboxName: (mailboxId: string) => string | undefined;
14
+ /** Open the row's rule in the editor (RFC 038 D6). */
15
+ onEdit: (filterId: string) => void;
14
16
  onDelete: (filterId: string) => void;
15
17
  deletingFilterId?: string;
18
+ /**
19
+ * This deployment ships no vector pipeline (RFC 038 D4). A filter carrying a
20
+ * semantic anchor lists with its widen chip inactive — it matches by its
21
+ * literal clauses only.
22
+ */
23
+ semanticUnavailable?: boolean;
16
24
  /** Injected for deterministic status in tests; defaults to now. */
17
25
  now?: number;
18
26
  }
19
27
 
20
28
  /**
21
- * The account's standing filters. Expired temporary filters stay listed and
22
- * are marked Expired distinctly rather than hidden (RFC 034 Decision 1.2).
29
+ * The account's standing filters, each opening its rule in the editor (RFC 038
30
+ * D6). Expired temporary filters stay listed and are marked Expired distinctly
31
+ * rather than hidden (RFC 034 Decision 1.2). A filter with a semantic anchor
32
+ * shows the widen chip — inactive where this deployment cannot evaluate it
33
+ * (D4), so the list says honestly that it matches by literal clauses only.
23
34
  */
24
35
  export function FiltersList({
25
36
  filters,
26
37
  mailboxName,
38
+ onEdit,
27
39
  onDelete,
28
40
  deletingFilterId,
41
+ semanticUnavailable = false,
29
42
  now = Date.now(),
30
43
  }: FiltersListProps) {
31
44
  if (filters.length === 0) {
@@ -53,7 +66,12 @@ export function FiltersList({
53
66
  key={filter.filterId}
54
67
  className="flex items-start gap-3 px-3 py-2.5"
55
68
  >
56
- <div className="min-w-0 flex-1">
69
+ <button
70
+ type="button"
71
+ onClick={() => onEdit(filter.filterId)}
72
+ aria-label={`Edit filter ${filter.name}`}
73
+ className="min-w-0 flex-1 rounded-sm text-left hover:opacity-80"
74
+ >
57
75
  <div className="flex items-center gap-2">
58
76
  <span
59
77
  className={`truncate text-sm font-medium ${
@@ -76,7 +94,17 @@ export function FiltersList({
76
94
  ? " · always"
77
95
  : ""}
78
96
  </p>
79
- </div>
97
+ {filter.hasAnchor && (
98
+ <div className="mt-1.5 flex flex-wrap gap-1.5">
99
+ <WidenChip
100
+ widen={{
101
+ anchorCount: 1,
102
+ ...(semanticUnavailable ? { inactive: true } : {}),
103
+ }}
104
+ />
105
+ </div>
106
+ )}
107
+ </button>
80
108
  <Button
81
109
  variant="ghost"
82
110
  size="sm"
@@ -0,0 +1,165 @@
1
+ import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import { BottomSheet, type FolderOption } from "@remit/ui";
3
+ import type { Meta, StoryObj } from "@storybook/react-vite";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import type { ReactNode } from "react";
6
+ import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
7
+ import { FilterEditor } from "./FilterEditor";
8
+
9
+ /**
10
+ * `Flows/Settings Filters` — editing a standing filter in the same chip editor
11
+ * the Organize surface uses (RFC 038 D6), rendered from the real web-client
12
+ * component the settings page mounts. Each story opens a persisted filter into
13
+ * the editor: a literal rule, an anchored rule with its widen chip, an
14
+ * until-a-date rule, and the degraded case where the deployment cannot evaluate
15
+ * the anchor and the widen lists inactive (D4).
16
+ */
17
+
18
+ const ACCOUNT_ID = "acc-1";
19
+
20
+ const FOLDERS: FolderOption[] = [
21
+ { id: "mbx-inbox", label: "Inbox" },
22
+ { id: "mbx-archive", label: "Archive" },
23
+ { id: "mbx-receipts", label: "Receipts" },
24
+ { id: "mbx-travel", label: "Travel" },
25
+ ];
26
+
27
+ const makeFilter = (
28
+ overrides: Partial<RemitImapFilterResponse> = {},
29
+ ): RemitImapFilterResponse => ({
30
+ filterId: "f-1",
31
+ accountConfigId: ACCOUNT_ID,
32
+ name: "Receipts",
33
+ scope: "Standing",
34
+ state: "Active",
35
+ hasAnchor: false,
36
+ ruleChangedAt: 0,
37
+ matchOperator: "And",
38
+ literalClauses: [
39
+ { field: "From", value: "receipts@stripe.com" },
40
+ { field: "Subject", value: "invoice" },
41
+ ],
42
+ actionLabelId: "None",
43
+ actionMailboxId: "mbx-receipts",
44
+ createdAt: 0,
45
+ updatedAt: 0,
46
+ ...overrides,
47
+ });
48
+
49
+ /**
50
+ * A bare client with no backend. The editor opens on `loading` and its preview
51
+ * settles to the count region's error state — these stories exercise the editor
52
+ * chrome (chips, read-only lifecycle, the widen chip), not the live count.
53
+ */
54
+ function seededClient(): QueryClient {
55
+ return new QueryClient({
56
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
57
+ });
58
+ }
59
+
60
+ /** Phone frame + a bottom sheet holding the editor, mirroring the mobile home. */
61
+ function SheetStage({ children }: { children: ReactNode }) {
62
+ return (
63
+ <QueryClientProvider client={seededClient()}>
64
+ <ErrorBannerProvider>
65
+ <div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[760px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
66
+ <div className="divide-y divide-line opacity-50">
67
+ {Array.from({ length: 8 }).map((_, index) => (
68
+ <div
69
+ // biome-ignore lint/suspicious/noArrayIndexKey: static backdrop skeleton, no ids
70
+ key={index}
71
+ className="flex items-start gap-3 px-row-inset py-2.5"
72
+ >
73
+ <div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
74
+ <div className="min-w-0 flex-1 space-y-1">
75
+ <div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
76
+ <div className="h-2 w-2/3 rounded bg-surface-sunken" />
77
+ </div>
78
+ </div>
79
+ ))}
80
+ </div>
81
+ <BottomSheet open onClose={() => undefined}>
82
+ {children}
83
+ </BottomSheet>
84
+ </div>
85
+ </ErrorBannerProvider>
86
+ </QueryClientProvider>
87
+ );
88
+ }
89
+
90
+ const meta: Meta = {
91
+ title: "Flows/Settings Filters",
92
+ parameters: { layout: "fullscreen" },
93
+ };
94
+ export default meta;
95
+
96
+ type Story = StoryObj;
97
+
98
+ /** A standing literal filter opened for editing. */
99
+ export const EditStandingRule: Story = {
100
+ name: "Edit Standing Rule",
101
+ render: () => (
102
+ <SheetStage>
103
+ <FilterEditor
104
+ accountId={ACCOUNT_ID}
105
+ filter={makeFilter()}
106
+ folders={FOLDERS}
107
+ onClose={() => undefined}
108
+ />
109
+ </SheetStage>
110
+ ),
111
+ };
112
+
113
+ /** A filter with a semantic anchor — the widen lists as an active chip. */
114
+ export const EditAnchoredRule: Story = {
115
+ name: "Edit Anchored Rule",
116
+ render: () => (
117
+ <SheetStage>
118
+ <FilterEditor
119
+ accountId={ACCOUNT_ID}
120
+ filter={makeFilter({ hasAnchor: true, name: "GitHub" })}
121
+ folders={FOLDERS}
122
+ onClose={() => undefined}
123
+ />
124
+ </SheetStage>
125
+ ),
126
+ };
127
+
128
+ /** A temporary filter — the scope loads as until-a-date with the stored date. */
129
+ export const EditUntilADate: Story = {
130
+ name: "Edit Until A Date",
131
+ render: () => (
132
+ <SheetStage>
133
+ <FilterEditor
134
+ accountId={ACCOUNT_ID}
135
+ filter={makeFilter({
136
+ name: "Conference",
137
+ scope: "Temporary",
138
+ expiresAt: "2027-09-01T23:59:59+00:00",
139
+ })}
140
+ folders={FOLDERS}
141
+ onClose={() => undefined}
142
+ />
143
+ </SheetStage>
144
+ ),
145
+ };
146
+
147
+ /**
148
+ * The deployment cannot evaluate the filter's anchor (RFC 038 D4): the widen
149
+ * chip lists inactive, the rule matches by its literal clauses only, and the
150
+ * dead anchor is removable.
151
+ */
152
+ export const DegradedAnchor: Story = {
153
+ name: "Degraded Anchor",
154
+ render: () => (
155
+ <SheetStage>
156
+ <FilterEditor
157
+ accountId={ACCOUNT_ID}
158
+ filter={makeFilter({ hasAnchor: true, name: "Newsletters" })}
159
+ folders={FOLDERS}
160
+ semanticUnavailable
161
+ onClose={() => undefined}
162
+ />
163
+ </SheetStage>
164
+ ),
165
+ };
@@ -22,20 +22,25 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
22
22
  * updates the count, so a slow early preview never overwrites a newer one. The
23
23
  * server bounds the match, so a broad predicate over a large mailbox stays a
24
24
  * single cheap request; the debounce is what keeps rapid edits from spamming it.
25
+ *
26
+ * `seedCount` is the opening count a widen probe already knows; omit it (as the
27
+ * settings editor does, having no probe) to open on `loading` and preview the
28
+ * loaded predicate immediately.
25
29
  */
26
30
  export const useRulePreview = (
27
31
  accountId: string | undefined,
28
32
  predicate: OrganizeMatchPredicate,
29
- seedCount: number,
33
+ seedCount?: number,
30
34
  ): PreviewCount => {
31
35
  const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
32
36
  const { mutateAsync } = mutation;
33
37
  const currentSignature = predicateSignature(predicate);
34
38
 
35
- const [state, setState] = useState<PreviewState>(() => ({
36
- count: seedCount,
37
- previewedSignature: currentSignature,
38
- }));
39
+ const [state, setState] = useState<PreviewState>(() =>
40
+ seedCount === undefined
41
+ ? {}
42
+ : { count: seedCount, previewedSignature: currentSignature },
43
+ );
39
44
 
40
45
  const predicateRef = useRef(predicate);
41
46
  predicateRef.current = predicate;
@@ -0,0 +1,56 @@
1
+ import {
2
+ filterDetailOperationsGetFilterQueryKey,
3
+ filterDetailOperationsUpdateFilterMutation,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type { RemitImapUpdateFilterInput } from "@remit/api-http-client/types.gen.ts";
6
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
7
+ import { useCallback } from "react";
8
+ import { buildFilterListKey } from "./useFilters";
9
+
10
+ /**
11
+ * Patch an existing filter (RFC 038 D6). The server bumps `ruleChangedAt` only
12
+ * when the body touches a predicate or action field, never on a name-only
13
+ * rename (RFC 034 Decision 3.2) — the caller decides which fields to send. On
14
+ * success both the account's filter list and this filter's detail are
15
+ * invalidated so the settings page and any open editor read the saved rule.
16
+ */
17
+ export const useUpdateFilter = (
18
+ accountId: string | undefined,
19
+ filterId: string | undefined,
20
+ ) => {
21
+ const queryClient = useQueryClient();
22
+ const mutation = useMutation({
23
+ ...filterDetailOperationsUpdateFilterMutation(),
24
+ onSuccess: () => {
25
+ if (!accountId) return;
26
+ queryClient.invalidateQueries({
27
+ queryKey: buildFilterListKey(accountId),
28
+ });
29
+ if (filterId) {
30
+ queryClient.invalidateQueries({
31
+ queryKey: filterDetailOperationsGetFilterQueryKey({
32
+ path: { accountId, filterId },
33
+ }),
34
+ });
35
+ }
36
+ },
37
+ });
38
+ const { mutate } = mutation;
39
+
40
+ const updateFilter = useCallback(
41
+ (body: RemitImapUpdateFilterInput) => {
42
+ if (!accountId || !filterId) return;
43
+ mutate({ path: { accountId, filterId }, body });
44
+ },
45
+ [accountId, filterId, mutate],
46
+ );
47
+
48
+ return {
49
+ updateFilter,
50
+ isPending: mutation.isPending,
51
+ isSuccess: mutation.isSuccess,
52
+ isError: mutation.isError,
53
+ error: mutation.error,
54
+ reset: mutation.reset,
55
+ };
56
+ };
@@ -0,0 +1,175 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import type { FilterRule } from "@remit/ui";
5
+ import {
6
+ buildUpdateFilterInput,
7
+ expiresAtToPickedDate,
8
+ filterToRule,
9
+ ruleChangesPredicateOrAction,
10
+ } from "./filter-edit-model";
11
+
12
+ const filter = (
13
+ overrides: Partial<RemitImapFilterResponse> = {},
14
+ ): RemitImapFilterResponse => ({
15
+ filterId: "f-1",
16
+ accountConfigId: "acc-1",
17
+ name: "Receipts",
18
+ scope: "Standing",
19
+ state: "Active",
20
+ hasAnchor: false,
21
+ ruleChangedAt: 0,
22
+ matchOperator: "And",
23
+ literalClauses: [{ field: "From", value: "receipts@stripe.com" }],
24
+ actionLabelId: "None",
25
+ actionMailboxId: "mbx-receipts",
26
+ createdAt: 0,
27
+ updatedAt: 0,
28
+ ...overrides,
29
+ });
30
+
31
+ describe("filterToRule", () => {
32
+ it("loads the persisted predicate, action and name unchanged", () => {
33
+ const rule = filterToRule(
34
+ filter({
35
+ matchOperator: "Or",
36
+ literalClauses: [
37
+ { field: "From", value: "a@x.com" },
38
+ { field: "Subject", value: "invoice" },
39
+ ],
40
+ }),
41
+ );
42
+ assert.equal(rule.matchOperator, "any");
43
+ assert.equal(rule.name, "Receipts");
44
+ assert.equal(rule.moveMailboxId, "mbx-receipts");
45
+ assert.equal(rule.scope, "standing");
46
+ assert.deepEqual(
47
+ rule.clauses.map((clause) => [clause.field, clause.value]),
48
+ [
49
+ ["From", "a@x.com"],
50
+ ["Subject", "invoice"],
51
+ ],
52
+ );
53
+ });
54
+
55
+ it("maps the None move sentinel to no action", () => {
56
+ const rule = filterToRule(filter({ actionMailboxId: "None" }));
57
+ assert.equal(rule.moveMailboxId, undefined);
58
+ });
59
+
60
+ it("loads a Temporary filter as an until-a-date rule", () => {
61
+ const rule = filterToRule(
62
+ filter({ scope: "Temporary", expiresAt: "2027-03-04T23:59:59+00:00" }),
63
+ );
64
+ assert.equal(rule.scope, "until");
65
+ assert.match(rule.until ?? "", /^\d{4}-\d{2}-\d{2}$/);
66
+ });
67
+
68
+ it("shows a semantic anchor as an active widen chip when the deployment can serve it", () => {
69
+ const rule = filterToRule(filter({ hasAnchor: true }));
70
+ assert.ok(rule.widen);
71
+ assert.equal(rule.widen?.inactive, undefined);
72
+ });
73
+
74
+ it("marks the widen inactive on a deployment that cannot evaluate it (D4)", () => {
75
+ const rule = filterToRule(filter({ hasAnchor: true }), true);
76
+ assert.equal(rule.widen?.inactive, true);
77
+ });
78
+
79
+ it("carries no widen when the filter has no anchor", () => {
80
+ assert.equal(filterToRule(filter({ hasAnchor: false })).widen, undefined);
81
+ });
82
+ });
83
+
84
+ describe("expiresAtToPickedDate", () => {
85
+ it("returns empty for absent or unparseable input", () => {
86
+ assert.equal(expiresAtToPickedDate(undefined), "");
87
+ assert.equal(expiresAtToPickedDate("not-a-date"), "");
88
+ });
89
+
90
+ it("reads the local calendar day", () => {
91
+ assert.match(
92
+ expiresAtToPickedDate("2027-03-04T12:00:00Z"),
93
+ /^2027-03-0[45]$/,
94
+ );
95
+ });
96
+ });
97
+
98
+ describe("ruleChangesPredicateOrAction", () => {
99
+ const base = filterToRule(filter({ hasAnchor: true }));
100
+
101
+ it("is false for an identical rule and for a rename only", () => {
102
+ assert.equal(ruleChangesPredicateOrAction(base, base), false);
103
+ assert.equal(
104
+ ruleChangesPredicateOrAction({ ...base, name: "New name" }, base),
105
+ false,
106
+ );
107
+ });
108
+
109
+ it("is true when a clause, operator, move target or widen changes", () => {
110
+ assert.equal(
111
+ ruleChangesPredicateOrAction({ ...base, matchOperator: "any" }, base),
112
+ true,
113
+ );
114
+ assert.equal(
115
+ ruleChangesPredicateOrAction(
116
+ { ...base, moveMailboxId: "mbx-other" },
117
+ base,
118
+ ),
119
+ true,
120
+ );
121
+ assert.equal(
122
+ ruleChangesPredicateOrAction({ ...base, widen: undefined }, base),
123
+ true,
124
+ );
125
+ assert.equal(
126
+ ruleChangesPredicateOrAction(
127
+ { ...base, clauses: [{ id: "c", field: "Subject", value: "x" }] },
128
+ base,
129
+ ),
130
+ true,
131
+ );
132
+ });
133
+ });
134
+
135
+ describe("buildUpdateFilterInput", () => {
136
+ const original = filterToRule(filter());
137
+
138
+ it("sends only the name for a cosmetic rename — no predicate fields", () => {
139
+ const body = buildUpdateFilterInput(
140
+ { ...original, name: "Invoices" },
141
+ original,
142
+ );
143
+ assert.deepEqual(body, { name: "Invoices" });
144
+ });
145
+
146
+ it("sends the predicate and action on a rule change, so the server bumps ruleChangedAt", () => {
147
+ const changed: FilterRule = {
148
+ ...original,
149
+ matchOperator: "any",
150
+ clauses: [{ id: "c", field: "ListId", value: "list.example.com" }],
151
+ moveMailboxId: "mbx-archive",
152
+ };
153
+ const body = buildUpdateFilterInput(changed, original);
154
+ assert.equal(body.matchOperator, "Or");
155
+ assert.equal(body.actionMailboxId, "mbx-archive");
156
+ assert.deepEqual(body.literalClauses, [
157
+ { field: "ListId", value: "list.example.com" },
158
+ ]);
159
+ assert.equal("name" in body, false);
160
+ });
161
+
162
+ it("drops a cleared move target to the None sentinel", () => {
163
+ const changed = {
164
+ ...original,
165
+ moveMailboxId: undefined,
166
+ matchOperator: "any" as const,
167
+ };
168
+ const body = buildUpdateFilterInput(changed, original);
169
+ assert.equal(body.actionMailboxId, "None");
170
+ });
171
+
172
+ it("is empty when nothing changed", () => {
173
+ assert.deepEqual(buildUpdateFilterInput(original, original), {});
174
+ });
175
+ });
@@ -0,0 +1,106 @@
1
+ import type {
2
+ RemitImapFilterResponse,
3
+ RemitImapUpdateFilterInput,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import type { FilterRule, RuleClause } from "@remit/ui";
6
+ import { NO_ACTION } from "./organize-model";
7
+
8
+ /**
9
+ * The persisted filter as the chip editor's rule (RFC 038 D6). Loads the whole
10
+ * rule the user edits: literal clauses, the match operator, the move action, the
11
+ * scope (and, for a Temporary filter, the date it stops on), and the semantic
12
+ * anchor as a widen chip. The anchor renders inactive when this deployment
13
+ * cannot evaluate it (D4) — the rule then matches by its literal clauses only.
14
+ *
15
+ * The scope maps `Standing` → "standing" and `Temporary` → "until"; a persisted
16
+ * filter is never the one-time "once" scope. `moveMailboxId` drops the `"None"`
17
+ * sentinel to `undefined` so an empty folder select reads as "no move action".
18
+ */
19
+ export const filterToRule = (
20
+ filter: RemitImapFilterResponse,
21
+ semanticUnavailable = false,
22
+ ): FilterRule => {
23
+ const clauses: RuleClause[] = filter.literalClauses.map((clause, index) => ({
24
+ id: `clause-${index}`,
25
+ field: clause.field,
26
+ value: clause.value,
27
+ }));
28
+ return {
29
+ clauses,
30
+ matchOperator: filter.matchOperator === "And" ? "all" : "any",
31
+ widen: filter.hasAnchor
32
+ ? { anchorCount: 1, ...(semanticUnavailable ? { inactive: true } : {}) }
33
+ : undefined,
34
+ moveMailboxId:
35
+ filter.actionMailboxId !== NO_ACTION ? filter.actionMailboxId : undefined,
36
+ scope: filter.scope === "Temporary" ? "until" : "standing",
37
+ until:
38
+ filter.scope === "Temporary"
39
+ ? expiresAtToPickedDate(filter.expiresAt)
40
+ : undefined,
41
+ name: filter.name,
42
+ };
43
+ };
44
+
45
+ /**
46
+ * The `YYYY-MM-DD` the date input shows for a stored `expiresAt`. The inverse of
47
+ * {@link pickedDateToExpiresAt}: it reads the local calendar day the timestamp
48
+ * falls on, so a date picked, saved, and reopened comes back unchanged. Empty
49
+ * or unparseable input yields an empty string — the picker's "no date" state.
50
+ */
51
+ export const expiresAtToPickedDate = (
52
+ expiresAt: string | undefined,
53
+ ): string => {
54
+ if (!expiresAt) return "";
55
+ const ms = Date.parse(expiresAt);
56
+ if (Number.isNaN(ms)) return "";
57
+ const date = new Date(ms);
58
+ const pad = (value: number) => String(value).padStart(2, "0");
59
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
60
+ };
61
+
62
+ const predicateActionKey = (rule: FilterRule): string =>
63
+ JSON.stringify({
64
+ clauses: rule.clauses.map((clause) => [clause.field, clause.value]),
65
+ operator: rule.matchOperator,
66
+ move: rule.moveMailboxId ?? null,
67
+ widen: rule.widen ? (rule.widen.inactive ? "inactive" : "active") : "none",
68
+ });
69
+
70
+ /**
71
+ * Whether the edited rule changes the predicate or the action versus the one it
72
+ * was loaded from — the clauses, the match operator, the move target, or the
73
+ * widen's presence. A change here is what bumps `ruleChangedAt` and offers the
74
+ * re-back-apply (RFC 034 Decision 3.2); the name is deliberately excluded, so a
75
+ * cosmetic rename is not a rule change.
76
+ */
77
+ export const ruleChangesPredicateOrAction = (
78
+ rule: FilterRule,
79
+ original: FilterRule,
80
+ ): boolean => predicateActionKey(rule) !== predicateActionKey(original);
81
+
82
+ /**
83
+ * The PATCH body for an edited filter. A cosmetic rename sends `{ name }` only,
84
+ * so the server's `changesPredicateOrAction` guard leaves `ruleChangedAt`
85
+ * 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/expiry — are
88
+ * absent from the patch, so the partial update preserves them.
89
+ */
90
+ export const buildUpdateFilterInput = (
91
+ rule: FilterRule,
92
+ original: FilterRule,
93
+ ): RemitImapUpdateFilterInput => {
94
+ const body: RemitImapUpdateFilterInput = {};
95
+ const name = (rule.name ?? "").trim();
96
+ if (name !== (original.name ?? "").trim()) body.name = name;
97
+ if (ruleChangesPredicateOrAction(rule, original)) {
98
+ body.matchOperator = rule.matchOperator === "all" ? "And" : "Or";
99
+ body.literalClauses = rule.clauses.map((clause) => ({
100
+ field: clause.field,
101
+ value: clause.value,
102
+ }));
103
+ body.actionMailboxId = rule.moveMailboxId ?? NO_ACTION;
104
+ }
105
+ return body;
106
+ };