@remit/web-client 0.0.60 → 0.0.62

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.60",
3
+ "version": "0.0.62",
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": {
@@ -8,7 +8,7 @@ interface AutoMovedIndicatorProps {
8
8
  threadId: string;
9
9
  mailboxId: string;
10
10
  autoMoved: RemitImapAutoMovedInfo | undefined;
11
- /** `md` (reading view) adds the inline Undo action; `sm` (list row) is icon + label only. */
11
+ /** `md` (reading view) adds the inline actions (Undo, and the Manage-filter link for a filter move); `sm` (list row) shows the icon + label. */
12
12
  size?: "sm" | "md";
13
13
  }
14
14
 
@@ -43,6 +43,7 @@ export function AutoMovedIndicator({
43
43
  size={size}
44
44
  onUndo={badge.onUndo}
45
45
  undoLabel={badge.isUndoing ? "Undoing…" : "Undo"}
46
+ filtersHref={size === "md" ? badge.filtersHref : undefined}
46
47
  />
47
48
  );
48
49
  }
@@ -7,7 +7,10 @@ import {
7
7
  export interface AppVersionProps {
8
8
  /** Override SHA for testing/Storybook. Defaults to the build-time constant. */
9
9
  sha?: string;
10
- /** Override commit URL. Defaults to the GitHub commit link. */
10
+ /**
11
+ * Override commit URL. Defaults to the GitHub commit link, which is undefined
12
+ * for a local "dev" build with no real SHA — the version renders unlinked then.
13
+ */
11
14
  commitUrl?: string;
12
15
  /** Override build time ISO string. Defaults to the build-time constant. */
13
16
  buildTime?: string;
@@ -35,14 +38,18 @@ export function AppVersion({
35
38
  return (
36
39
  <p className="text-xs text-fg-subtle">
37
40
  Version{" "}
38
- <a
39
- href={commitUrl}
40
- target="_blank"
41
- rel="noopener noreferrer"
42
- className="font-mono hover:text-fg-muted hover:underline"
43
- >
44
- {sha}
45
- </a>
41
+ {commitUrl ? (
42
+ <a
43
+ href={commitUrl}
44
+ target="_blank"
45
+ rel="noopener noreferrer"
46
+ className="font-mono hover:text-fg-muted hover:underline"
47
+ >
48
+ {sha}
49
+ </a>
50
+ ) : (
51
+ <span className="font-mono">{sha}</span>
52
+ )}
46
53
  {" · "}
47
54
  <span>Built {formatBuildTime(buildTime)}</span>
48
55
  </p>
@@ -1,10 +1,13 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
2
  import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQuery } from "@tanstack/react-query";
2
4
  import { useCallback } from "react";
3
5
  import {
4
6
  autoMovedLabel,
5
7
  isAutoMoveInEffect,
6
8
  resolveUndoTargetMailboxId,
7
9
  } from "@/lib/auto-moved";
10
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
8
11
  import { useInboxMailbox, useJunkMailbox } from "./useArchiveMailbox";
9
12
  import { useMoveMessages } from "./useMoveMessages";
10
13
 
@@ -24,14 +27,26 @@ export interface AutoMovedBadgeState {
24
27
  /** Present only when the undo target mailbox resolved. */
25
28
  onUndo?: () => void;
26
29
  isUndoing: boolean;
30
+ /**
31
+ * Settings › Filters link, present only for a standing-filter move — undo
32
+ * returns the message but never disables the filter, so the badge points to
33
+ * where the filter can be managed.
34
+ */
35
+ filtersHref?: string;
27
36
  }
28
37
 
29
38
  /**
30
- * Composes the account's Inbox/Junk mailboxes with the message's `autoMoved`
31
- * projection into everything the `AutoMovedBadge` kit component needs: the
32
- * derived "still in effect" gate, the plain-language label, and a one-click
33
- * undo bound to the existing `moveMessages` mutation (no new endpoint — moves
34
- * back through the same bulk move operation, the other direction).
39
+ * Composes the account's mailboxes with the message's `autoMoved` projection
40
+ * into everything the `AutoMovedBadge` kit component needs: the derived "still
41
+ * in effect" gate, the plain-language label, and a one-click undo bound to the
42
+ * existing `moveMessages` mutation (no new endpoint — moves back through the
43
+ * same bulk move operation, the other direction).
44
+ *
45
+ * Both auto-move shapes are handled. A classifier move resolves its
46
+ * Inbox/Junk role mailboxes; a standing-filter move names an arbitrary source
47
+ * folder, whose display name is resolved from the account's mailbox list, and
48
+ * carries a Settings › Filters link so the filter that keeps moving mail is one
49
+ * tap away — undo does not disable it.
35
50
  *
36
51
  * `show` re-derives on every render from `mailboxId` — no local dismissed
37
52
  * flag. Once `moveMessages` settles, its query invalidation refetches the
@@ -53,11 +68,20 @@ export const useAutoMovedBadge = ({
53
68
  accountId,
54
69
  });
55
70
 
71
+ const { data: mailboxes } = useQuery({
72
+ ...mailboxOperationsListMailboxesOptions({
73
+ path: { accountId: accountId ?? "" },
74
+ }),
75
+ staleTime: Infinity,
76
+ enabled: !!accountId && autoMoved?.fromMailboxId !== undefined,
77
+ });
78
+
56
79
  const roleMailboxes = { inboxMailboxId, junkMailboxId };
57
80
  const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
58
- const undoTargetMailboxId = autoMoved
59
- ? resolveUndoTargetMailboxId(autoMoved.fromPlacement, roleMailboxes)
60
- : undefined;
81
+ const undoTargetMailboxId = resolveUndoTargetMailboxId(
82
+ autoMoved,
83
+ roleMailboxes,
84
+ );
61
85
 
62
86
  const handleUndo = useCallback(() => {
63
87
  if (!undoTargetMailboxId) return;
@@ -68,10 +92,17 @@ export const useAutoMovedBadge = ({
68
92
  return { show: false, label: "", isUndoing: false };
69
93
  }
70
94
 
95
+ const sourceFolderName = autoMoved.fromMailboxId
96
+ ? mailboxes?.items
97
+ .filter((mailbox) => mailbox.mailboxId === autoMoved.fromMailboxId)
98
+ .map((mailbox) => getMailboxDisplayName(mailbox.fullPath))[0]
99
+ : undefined;
100
+
71
101
  return {
72
102
  show: true,
73
- label: autoMovedLabel(autoMoved.fromPlacement),
103
+ label: autoMovedLabel(autoMoved, sourceFolderName),
74
104
  onUndo: undoTargetMailboxId ? handleUndo : undefined,
75
105
  isUndoing: isPending,
106
+ ...(autoMoved.filterId ? { filtersHref: "/settings/filters" } : {}),
76
107
  };
77
108
  };
@@ -10,6 +10,18 @@ export const APP_BUILD_TIME: string = __APP_BUILD_TIME__;
10
10
  /** First 7 characters of the SHA, matching git's default short form. */
11
11
  export const APP_SHORT_SHA: string = APP_SHA.slice(0, 7);
12
12
 
13
- export const GITHUB_COMMIT_URL = `https://github.com/remit-mail/reader/commit/${APP_SHA}`;
13
+ /**
14
+ * True when `APP_SHA` is a real git commit — a build that was given the commit
15
+ * (via `GITHUB_SHA` at build time). A local build with no git resolves to the
16
+ * literal "dev", which is not a commit and must not be linked (a
17
+ * `/commit/dev` URL is a dead link).
18
+ */
19
+ export const APP_SHA_IS_COMMIT = /^[0-9a-f]{40}$/.test(APP_SHA);
20
+
21
+ /** Commit URL for the build, or undefined when the build has no real SHA. */
22
+ export const GITHUB_COMMIT_URL: string | undefined = APP_SHA_IS_COMMIT
23
+ ? `https://github.com/remit-mail/reader/commit/${APP_SHA}`
24
+ : undefined;
25
+
14
26
  export const GITHUB_NEW_ISSUE_URL =
15
27
  "https://github.com/remit-mail/reader/issues/new";
@@ -14,23 +14,65 @@ const ROLE_MAILBOXES: AutoMovedRoleMailboxes = {
14
14
  junkMailboxId: "mb-junk",
15
15
  };
16
16
 
17
+ const classifierMove = (
18
+ action: RemitImapAutoMovedInfo["action"],
19
+ fromPlacement: string,
20
+ ): RemitImapAutoMovedInfo => ({ action, fromPlacement });
21
+
22
+ const filterMove = (
23
+ fromMailboxId: string,
24
+ destinationMailboxId: string,
25
+ ): RemitImapAutoMovedInfo => ({
26
+ fromMailboxId,
27
+ destinationMailboxId,
28
+ filterId: "flt-1",
29
+ });
30
+
17
31
  describe("autoMovedLabel", () => {
18
- test("reads 'Moved from Junk by Remit'", () => {
19
- assert.equal(autoMovedLabel("junk"), "Moved from Junk by Remit");
32
+ test("classifier move reads 'Moved from Junk by Remit'", () => {
33
+ assert.equal(
34
+ autoMovedLabel(classifierMove(PlacementAction.MoveToInbox, "junk")),
35
+ "Moved from Junk by Remit",
36
+ );
20
37
  });
21
38
 
22
- test("reads 'Moved from Inbox by Remit'", () => {
23
- assert.equal(autoMovedLabel("inbox"), "Moved from Inbox by Remit");
39
+ test("classifier move reads 'Moved from Inbox by Remit'", () => {
40
+ assert.equal(
41
+ autoMovedLabel(classifierMove(PlacementAction.MoveToJunk, "inbox")),
42
+ "Moved from Inbox by Remit",
43
+ );
24
44
  });
25
45
 
26
- test("falls back to plain language for an unrecognized placement", () => {
27
- assert.equal(autoMovedLabel("other"), "Moved from another folder by Remit");
46
+ test("classifier move falls back to plain language for an unrecognized placement", () => {
47
+ assert.equal(
48
+ autoMovedLabel(classifierMove(PlacementAction.MoveToInbox, "other")),
49
+ "Moved from another folder by Remit",
50
+ );
51
+ });
52
+
53
+ test("filter move names the resolved source folder", () => {
54
+ assert.equal(
55
+ autoMovedLabel(filterMove("mb-inbox", "mb-travel"), "Travel"),
56
+ "Moved from Travel by Remit",
57
+ );
58
+ });
59
+
60
+ test("filter move falls back to 'another folder' before the name resolves", () => {
61
+ assert.equal(
62
+ autoMovedLabel(filterMove("mb-inbox", "mb-travel")),
63
+ "Moved from another folder by Remit",
64
+ );
28
65
  });
29
66
 
30
67
  test("never leaks verdict jargon", () => {
31
- for (const placement of ["inbox", "junk", "other"]) {
68
+ const cases = [
69
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
70
+ classifierMove(PlacementAction.MoveToJunk, "inbox"),
71
+ filterMove("mb-inbox", "mb-travel"),
72
+ ];
73
+ for (const autoMoved of cases) {
32
74
  assert.doesNotMatch(
33
- autoMovedLabel(placement),
75
+ autoMovedLabel(autoMoved),
34
76
  /confiden|dry.?run|verdict/i,
35
77
  );
36
78
  }
@@ -46,94 +88,138 @@ describe("isAutoMoveInEffect", () => {
46
88
  });
47
89
 
48
90
  test("false when currentMailboxId is absent", () => {
49
- const autoMoved: RemitImapAutoMovedInfo = {
50
- action: PlacementAction.MoveToInbox,
51
- fromPlacement: "junk",
52
- };
53
91
  assert.equal(
54
- isAutoMoveInEffect(autoMoved, undefined, ROLE_MAILBOXES),
92
+ isAutoMoveInEffect(
93
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
94
+ undefined,
95
+ ROLE_MAILBOXES,
96
+ ),
55
97
  false,
56
98
  );
57
99
  });
58
100
 
59
- test("true when a MoveToInbox message currently sits in the Inbox mailbox", () => {
60
- const autoMoved: RemitImapAutoMovedInfo = {
61
- action: PlacementAction.MoveToInbox,
62
- fromPlacement: "junk",
63
- };
101
+ test("classifier: true when a MoveToInbox message sits in the Inbox mailbox", () => {
64
102
  assert.equal(
65
- isAutoMoveInEffect(autoMoved, "mb-inbox", ROLE_MAILBOXES),
103
+ isAutoMoveInEffect(
104
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
105
+ "mb-inbox",
106
+ ROLE_MAILBOXES,
107
+ ),
66
108
  true,
67
109
  );
68
110
  });
69
111
 
70
- test("true when a MoveToJunk message currently sits in the Junk mailbox", () => {
71
- const autoMoved: RemitImapAutoMovedInfo = {
72
- action: PlacementAction.MoveToJunk,
73
- fromPlacement: "inbox",
74
- };
112
+ test("classifier: true when a MoveToJunk message sits in the Junk mailbox", () => {
75
113
  assert.equal(
76
- isAutoMoveInEffect(autoMoved, "mb-junk", ROLE_MAILBOXES),
114
+ isAutoMoveInEffect(
115
+ classifierMove(PlacementAction.MoveToJunk, "inbox"),
116
+ "mb-junk",
117
+ ROLE_MAILBOXES,
118
+ ),
77
119
  true,
78
120
  );
79
121
  });
80
122
 
81
- test("false once the message has moved elsewhere (self-hide on reconcile)", () => {
82
- const autoMoved: RemitImapAutoMovedInfo = {
83
- action: PlacementAction.MoveToInbox,
84
- fromPlacement: "junk",
85
- };
123
+ test("classifier: false once the message has moved elsewhere", () => {
124
+ const autoMoved = classifierMove(PlacementAction.MoveToInbox, "junk");
86
125
  assert.equal(
87
126
  isAutoMoveInEffect(autoMoved, "mb-archive", ROLE_MAILBOXES),
88
127
  false,
89
128
  );
90
- // Undone back to Junk — no longer in the implied destination.
91
129
  assert.equal(
92
130
  isAutoMoveInEffect(autoMoved, "mb-junk", ROLE_MAILBOXES),
93
131
  false,
94
132
  );
95
133
  });
96
134
 
97
- test("false when the implied destination mailbox hasn't resolved yet", () => {
98
- const autoMoved: RemitImapAutoMovedInfo = {
99
- action: PlacementAction.MoveToInbox,
100
- fromPlacement: "junk",
101
- };
135
+ test("filter: true while the message sits in the filter's destination", () => {
136
+ assert.equal(
137
+ isAutoMoveInEffect(
138
+ filterMove("mb-inbox", "mb-travel"),
139
+ "mb-travel",
140
+ ROLE_MAILBOXES,
141
+ ),
142
+ true,
143
+ );
144
+ });
145
+
146
+ test("filter: false once undone back to the source folder", () => {
147
+ assert.equal(
148
+ isAutoMoveInEffect(
149
+ filterMove("mb-inbox", "mb-travel"),
150
+ "mb-inbox",
151
+ ROLE_MAILBOXES,
152
+ ),
153
+ false,
154
+ );
155
+ });
156
+
157
+ test("filter: in-effect does not depend on the Inbox/Junk role mailboxes", () => {
102
158
  assert.equal(
103
- isAutoMoveInEffect(autoMoved, "mb-inbox", {
159
+ isAutoMoveInEffect(filterMove("mb-inbox", "mb-travel"), "mb-travel", {
104
160
  inboxMailboxId: undefined,
105
- junkMailboxId: "mb-junk",
161
+ junkMailboxId: undefined,
106
162
  }),
163
+ true,
164
+ );
165
+ });
166
+
167
+ test("classifier: false when the implied destination mailbox hasn't resolved yet", () => {
168
+ assert.equal(
169
+ isAutoMoveInEffect(
170
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
171
+ "mb-inbox",
172
+ { inboxMailboxId: undefined, junkMailboxId: "mb-junk" },
173
+ ),
107
174
  false,
108
175
  );
109
176
  });
110
177
  });
111
178
 
112
179
  describe("resolveUndoTargetMailboxId", () => {
113
- test("resolves the Junk mailbox for fromPlacement='junk'", () => {
114
- assert.equal(resolveUndoTargetMailboxId("junk", ROLE_MAILBOXES), "mb-junk");
180
+ test("classifier: resolves the Junk mailbox for fromPlacement='junk'", () => {
181
+ assert.equal(
182
+ resolveUndoTargetMailboxId(
183
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
184
+ ROLE_MAILBOXES,
185
+ ),
186
+ "mb-junk",
187
+ );
115
188
  });
116
189
 
117
- test("resolves the Inbox mailbox for fromPlacement='inbox'", () => {
190
+ test("classifier: resolves the Inbox mailbox for fromPlacement='inbox'", () => {
118
191
  assert.equal(
119
- resolveUndoTargetMailboxId("inbox", ROLE_MAILBOXES),
192
+ resolveUndoTargetMailboxId(
193
+ classifierMove(PlacementAction.MoveToJunk, "inbox"),
194
+ ROLE_MAILBOXES,
195
+ ),
120
196
  "mb-inbox",
121
197
  );
122
198
  });
123
199
 
124
- test("undefined for an unresolvable placement", () => {
200
+ test("classifier: undefined when the role mailbox hasn't resolved yet", () => {
125
201
  assert.equal(
126
- resolveUndoTargetMailboxId("other", ROLE_MAILBOXES),
202
+ resolveUndoTargetMailboxId(
203
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
204
+ { inboxMailboxId: "mb-inbox", junkMailboxId: undefined },
205
+ ),
127
206
  undefined,
128
207
  );
129
208
  });
130
209
 
131
- test("undefined when the role mailbox hasn't resolved yet", () => {
210
+ test("filter: undo targets the recorded source mailbox verbatim", () => {
132
211
  assert.equal(
133
- resolveUndoTargetMailboxId("junk", {
134
- inboxMailboxId: "mb-inbox",
135
- junkMailboxId: undefined,
136
- }),
212
+ resolveUndoTargetMailboxId(
213
+ filterMove("mb-work", "mb-travel"),
214
+ ROLE_MAILBOXES,
215
+ ),
216
+ "mb-work",
217
+ );
218
+ });
219
+
220
+ test("undefined when autoMoved is absent", () => {
221
+ assert.equal(
222
+ resolveUndoTargetMailboxId(undefined, ROLE_MAILBOXES),
137
223
  undefined,
138
224
  );
139
225
  });
@@ -12,17 +12,35 @@ const FROM_PLACEMENT_LABEL: Record<string, string> = {
12
12
  junk: "Junk",
13
13
  };
14
14
 
15
+ /** A standing-filter move names an arbitrary destination via `destinationMailboxId`; a classifier move names a direction via `action`. */
16
+ const isFilterMove = (autoMoved: RemitImapAutoMovedInfo): boolean =>
17
+ autoMoved.destinationMailboxId !== undefined;
18
+
15
19
  /**
16
20
  * Plain-language badge text, e.g. "Moved from Junk by Remit". No jargon
17
21
  * (verdict/confidence/dryRun never surface) — mirrors the no-jargon precedent
18
22
  * in `rescue-candidates.ts`.
23
+ *
24
+ * A classifier move reads its source from the `fromPlacement` role. A
25
+ * standing-filter move moved from an arbitrary folder, so the caller resolves
26
+ * that folder's display name and passes it as `sourceFolderName`; absent (name
27
+ * not resolved yet) falls back to "another folder".
19
28
  */
20
- export const autoMovedLabel = (fromPlacement: string | undefined): string =>
21
- `Moved from ${FROM_PLACEMENT_LABEL[fromPlacement ?? ""] ?? "another folder"} by Remit`;
29
+ export const autoMovedLabel = (
30
+ autoMoved: RemitImapAutoMovedInfo,
31
+ sourceFolderName?: string,
32
+ ): string => {
33
+ if (isFilterMove(autoMoved)) {
34
+ return `Moved from ${sourceFolderName ?? "another folder"} by Remit`;
35
+ }
36
+ const from = autoMoved.fromPlacement ?? "";
37
+ return `Moved from ${FROM_PLACEMENT_LABEL[from] ?? "another folder"} by Remit`;
38
+ };
22
39
 
23
40
  /**
24
- * The mailbox the verdict's `action` implies as the destination — where the
25
- * message should currently sit for the move to still be "in effect".
41
+ * The mailbox the verdict's `action` implies as the destination — where a
42
+ * classifier-moved message should currently sit for the move to still be "in
43
+ * effect".
26
44
  */
27
45
  const impliedDestinationMailboxId = (
28
46
  action: string | undefined,
@@ -34,12 +52,13 @@ const impliedDestinationMailboxId = (
34
52
  };
35
53
 
36
54
  /**
37
- * True only while the auto-move is still in effect: the message currently
38
- * sits in the mailbox the verdict's `action` implied. Once the message moves
39
- * elsewhere (an undo, or any other move), this returns false — the badge has
40
- * no local "dismissed" flag; it re-derives from current placement every
41
- * render, per the data-flow rule that a projection never forks from its
42
- * source (doc/rules/data-flow.md).
55
+ * True only while the auto-move is still in effect: the message currently sits
56
+ * in the mailbox the move targeted. A standing-filter move targets the
57
+ * `destinationMailboxId` it recorded; a classifier move targets the mailbox its
58
+ * `action` implies. Once the message moves elsewhere (an undo, or any other
59
+ * move), this returns false the badge has no local "dismissed" flag; it
60
+ * re-derives from current placement every render, per the data-flow rule that a
61
+ * projection never forks from its source (doc/rules/data-flow.md).
43
62
  */
44
63
  export const isAutoMoveInEffect = (
45
64
  autoMoved: RemitImapAutoMovedInfo | undefined,
@@ -47,22 +66,29 @@ export const isAutoMoveInEffect = (
47
66
  mailboxes: AutoMovedRoleMailboxes,
48
67
  ): boolean => {
49
68
  if (!autoMoved || !currentMailboxId) return false;
50
- const destination = impliedDestinationMailboxId(autoMoved.action, mailboxes);
69
+ const destination = isFilterMove(autoMoved)
70
+ ? autoMoved.destinationMailboxId
71
+ : impliedDestinationMailboxId(autoMoved.action, mailboxes);
51
72
  return destination !== undefined && destination === currentMailboxId;
52
73
  };
53
74
 
54
75
  /**
55
- * Resolve the undo destination: the mailbox filling the role the message was
56
- * moved from. `fromPlacement` is always `inbox` or `junk` for an actionable
57
- * verdict (the Tier 0 classifier only fires move-to-inbox from junk and
58
- * move-to-junk from inbox see `classifyPlacement.ts`); `undefined` when the
59
- * account has no mailbox appointed to that role.
76
+ * Resolve the undo destination: where the message was before the move. A
77
+ * standing-filter move recorded the exact source mailbox (`fromMailboxId`),
78
+ * used verbatim. A classifier move recorded only a role (`fromPlacement`,
79
+ * always `inbox` or `junk` for an actionable verdict — the Tier 0 classifier
80
+ * only fires move-to-inbox from junk and move-to-junk from inbox, see
81
+ * `classifyPlacement.ts`), resolved to the account's mailbox for that role.
82
+ * `undefined` when the account has no mailbox for that role, or the source
83
+ * cannot be resolved.
60
84
  */
61
85
  export const resolveUndoTargetMailboxId = (
62
- fromPlacement: string | undefined,
86
+ autoMoved: RemitImapAutoMovedInfo | undefined,
63
87
  mailboxes: AutoMovedRoleMailboxes,
64
88
  ): string | undefined => {
65
- if (fromPlacement === "inbox") return mailboxes.inboxMailboxId;
66
- if (fromPlacement === "junk") return mailboxes.junkMailboxId;
89
+ if (!autoMoved) return undefined;
90
+ if (isFilterMove(autoMoved)) return autoMoved.fromMailboxId;
91
+ if (autoMoved.fromPlacement === "inbox") return mailboxes.inboxMailboxId;
92
+ if (autoMoved.fromPlacement === "junk") return mailboxes.junkMailboxId;
67
93
  return undefined;
68
94
  };
@@ -9,10 +9,13 @@ import {
9
9
  } from "@remit/ui";
10
10
  import type { BugReportContext } from "./bug-report";
11
11
  import { buildBugReportDetails, buildGitHubIssueUrl } from "./bug-report";
12
+ import type { RequestBreadcrumb } from "./request-breadcrumbs";
13
+ import type { RouteBreadcrumb } from "./route-breadcrumbs";
12
14
 
13
15
  const baseCtx: BugReportContext = {
14
- appSha: "abcdef1234567890abcdef1234567890abcdef12",
15
16
  appShortSha: "abcdef1",
17
+ appCommitUrl:
18
+ "https://github.com/remit-mail/reader/commit/abcdef1234567890abcdef1234567890abcdef12",
16
19
  appBuildTime: "2024-01-15T10:30:00.000Z",
17
20
  userAgent: "Mozilla/5.0 (Test Browser)",
18
21
  viewport: "1440×900",
@@ -20,6 +23,8 @@ const baseCtx: BugReportContext = {
20
23
  timezone: "Europe/Amsterdam",
21
24
  href: "https://app.example.com/mail/inbox?q=test",
22
25
  recentErrors: [],
26
+ requests: [],
27
+ routes: [],
23
28
  };
24
29
 
25
30
  /**
@@ -145,6 +150,144 @@ describe("buildGitHubIssueUrl", () => {
145
150
  const url = buildGitHubIssueUrl(ctx);
146
151
  assert.doesNotThrow(() => new URL(url), "URL must be valid");
147
152
  });
153
+
154
+ it("links the short SHA to the build commit", () => {
155
+ const decoded = decode(buildGitHubIssueUrl(baseCtx));
156
+ assert.ok(
157
+ decoded.includes(
158
+ "[`abcdef1`](https://github.com/remit-mail/reader/commit/abcdef1234567890abcdef1234567890abcdef12)",
159
+ ),
160
+ "Expected the version to link the commit",
161
+ );
162
+ });
163
+
164
+ it("renders the version unlinked when the build has no real SHA", () => {
165
+ const decoded = decode(
166
+ buildGitHubIssueUrl({
167
+ ...baseCtx,
168
+ appShortSha: "dev",
169
+ appCommitUrl: undefined,
170
+ }),
171
+ );
172
+ assert.ok(
173
+ decoded.includes("**Version**: `dev`"),
174
+ "Expected an unlinked dev version",
175
+ );
176
+ assert.ok(
177
+ !decoded.includes("/commit/dev"),
178
+ "A dev build must not produce a dead commit link",
179
+ );
180
+ });
181
+ });
182
+
183
+ describe("buildGitHubIssueUrl — request breadcrumbs", () => {
184
+ const requests: RequestBreadcrumb[] = [
185
+ {
186
+ method: "GET",
187
+ path: "/api/messages/abc-123",
188
+ status: 200,
189
+ durationMs: 42,
190
+ correlationId: "corr-ok",
191
+ timestamp: "2024-06-12T08:00:01.000Z",
192
+ },
193
+ {
194
+ method: "POST",
195
+ path: "/api/messages/abc-123/move",
196
+ status: 500,
197
+ durationMs: 130,
198
+ correlationId: "corr-fail",
199
+ timestamp: "2024-06-12T08:00:02.000Z",
200
+ },
201
+ ];
202
+
203
+ it("renders a table of recent API requests", () => {
204
+ const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, requests }));
205
+ assert.ok(decoded.includes("## Recent API requests"));
206
+ assert.ok(decoded.includes("/api/messages/abc-123/move"));
207
+ assert.ok(
208
+ decoded.includes("corr-fail"),
209
+ "Expected the correlation id in the table",
210
+ );
211
+ assert.ok(decoded.includes("| GET |"));
212
+ });
213
+
214
+ it("shows (none) when no requests were captured", () => {
215
+ const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, requests: [] }));
216
+ assert.ok(decoded.includes("## Recent API requests"));
217
+ });
218
+
219
+ it("calls out the failing request in its own section", () => {
220
+ const failingRequest = requests[1];
221
+ const decoded = decode(
222
+ buildGitHubIssueUrl({ ...baseCtx, requests, failingRequest }),
223
+ );
224
+ assert.ok(decoded.includes("## Failing request"));
225
+ assert.ok(decoded.includes("**Endpoint**: `/api/messages/abc-123/move`"));
226
+ assert.ok(decoded.includes("**Method**: POST"));
227
+ assert.ok(decoded.includes("**Status**: 500"));
228
+ assert.ok(decoded.includes("**Correlation id**: corr-fail"));
229
+ });
230
+
231
+ it("describes a transport failure as no response", () => {
232
+ const failingRequest: RequestBreadcrumb = {
233
+ method: "GET",
234
+ path: "/api/messages",
235
+ status: 0,
236
+ durationMs: 5000,
237
+ timestamp: "2024-06-12T08:00:03.000Z",
238
+ };
239
+ const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, failingRequest }));
240
+ assert.ok(decoded.includes("no response (transport failure)"));
241
+ assert.ok(decoded.includes("**Correlation id**: (none)"));
242
+ });
243
+
244
+ it("no breadcrumb ever carries a query string or a body", () => {
245
+ // A breadcrumb path is redacted at capture (request-breadcrumbs.ts), so
246
+ // even if the fetch layer saw a `?q=` search or a request body, the
247
+ // assembled requests table carries neither. Scope the check to that
248
+ // section — the `## URL` section legitimately keeps the full href.
249
+ const requestsWithQueryShaped: RequestBreadcrumb[] = [
250
+ {
251
+ method: "POST",
252
+ path: "/api/search",
253
+ status: 200,
254
+ durationMs: 5,
255
+ correlationId: "c1",
256
+ timestamp: "2024-06-12T08:00:01.000Z",
257
+ },
258
+ ];
259
+ const decoded = decode(
260
+ buildGitHubIssueUrl({ ...baseCtx, requests: requestsWithQueryShaped }),
261
+ );
262
+ const section = decoded.slice(decoded.indexOf("## Recent API requests"));
263
+ assert.ok(
264
+ !section.includes("?"),
265
+ "A breadcrumb path must never carry a query string",
266
+ );
267
+ assert.ok(
268
+ !/"password"|"subject"|"body":/.test(section),
269
+ "A breadcrumb must never carry a request/response body",
270
+ );
271
+ });
272
+ });
273
+
274
+ describe("buildGitHubIssueUrl — navigation trail", () => {
275
+ const routes: RouteBreadcrumb[] = [
276
+ { path: "/mail/inbox", timestamp: "2024-06-12T07:59:58.000Z" },
277
+ { path: "/mail/message/abc-123", timestamp: "2024-06-12T08:00:00.000Z" },
278
+ ];
279
+
280
+ it("renders the navigation trail so Steps to reproduce has context", () => {
281
+ const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, routes }));
282
+ assert.ok(decoded.includes("## Navigation trail"));
283
+ assert.ok(decoded.includes("/mail/inbox"));
284
+ assert.ok(decoded.includes("/mail/message/abc-123"));
285
+ });
286
+
287
+ it("still emits the Steps to reproduce template", () => {
288
+ const decoded = decode(buildGitHubIssueUrl({ ...baseCtx, routes }));
289
+ assert.ok(decoded.includes("## Steps to reproduce"));
290
+ });
148
291
  });
149
292
 
150
293
  describe("buildGitHubIssueUrl — seeded from a caught error", () => {
@@ -1,11 +1,17 @@
1
1
  import type { QuarantineReportSections } from "@remit/ui";
2
2
  import {
3
3
  APP_BUILD_TIME,
4
- APP_SHA,
5
4
  APP_SHORT_SHA,
5
+ GITHUB_COMMIT_URL,
6
6
  GITHUB_NEW_ISSUE_URL,
7
7
  } from "./app-info";
8
8
  import { getRecentErrors } from "./console-errors";
9
+ import {
10
+ getFailingRequest,
11
+ getRecentRequests,
12
+ type RequestBreadcrumb,
13
+ } from "./request-breadcrumbs";
14
+ import { getRecentRoutes, type RouteBreadcrumb } from "./route-breadcrumbs";
9
15
 
10
16
  /**
11
17
  * A GitHub new-issue URL is capped (~8 KB). We budget below that so the stack
@@ -19,8 +25,9 @@ const TRUNCATION_MARKER =
19
25
  "\n… (truncated — the copy action on this screen has the full report)";
20
26
 
21
27
  export interface BugReportContext {
22
- appSha: string;
23
28
  appShortSha: string;
29
+ /** Commit URL for the build, or undefined for a local "dev" build (no link). */
30
+ appCommitUrl?: string;
24
31
  appBuildTime: string;
25
32
  userAgent: string;
26
33
  viewport: string;
@@ -28,6 +35,12 @@ export interface BugReportContext {
28
35
  timezone: string;
29
36
  href: string;
30
37
  recentErrors: readonly string[];
38
+ /** Recent API calls, metadata only — method/path/status/duration/correlation. */
39
+ requests: readonly RequestBreadcrumb[];
40
+ /** Recent in-app navigations, oldest first. */
41
+ routes: readonly RouteBreadcrumb[];
42
+ /** The request that most likely triggered the report (transport or >= 400). */
43
+ failingRequest?: RequestBreadcrumb;
31
44
  /** The message of the error that triggered the report, when seeded from one. */
32
45
  errorMessage?: string;
33
46
  /** The error's stacktrace, when available. */
@@ -56,8 +69,8 @@ export interface BugReportSeed {
56
69
 
57
70
  export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
58
71
  return {
59
- appSha: APP_SHA,
60
72
  appShortSha: APP_SHORT_SHA,
73
+ appCommitUrl: GITHUB_COMMIT_URL,
61
74
  appBuildTime: APP_BUILD_TIME,
62
75
  userAgent: navigator.userAgent,
63
76
  viewport: `${window.innerWidth}×${window.innerHeight}`,
@@ -65,6 +78,9 @@ export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
65
78
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
66
79
  href: window.location.href,
67
80
  recentErrors: getRecentErrors(),
81
+ requests: getRecentRequests(),
82
+ routes: getRecentRoutes(),
83
+ failingRequest: getFailingRequest(),
68
84
  errorMessage: seed?.errorMessage,
69
85
  stack: seed?.stack,
70
86
  componentStack: seed?.componentStack,
@@ -77,6 +93,59 @@ function fencedBlock(content: string): string {
77
93
  return ["```", content, "```"].join("\n");
78
94
  }
79
95
 
96
+ /** `2024-06-12T08:00:01.234Z` → `08:00:01` — the wall-clock time, compact. */
97
+ function clockTime(iso: string): string {
98
+ const match = /T(\d{2}:\d{2}:\d{2})/.exec(iso);
99
+ return match ? match[1] : iso;
100
+ }
101
+
102
+ function versionLine(ctx: BugReportContext): string {
103
+ const version = ctx.appCommitUrl
104
+ ? `[\`${ctx.appShortSha}\`](${ctx.appCommitUrl})`
105
+ : `\`${ctx.appShortSha}\``;
106
+ return `- **Version**: ${version} built ${ctx.appBuildTime}`;
107
+ }
108
+
109
+ function failingRequestSection(request: RequestBreadcrumb): string[] {
110
+ const status =
111
+ request.status === 0
112
+ ? "no response (transport failure)"
113
+ : `${request.status}`;
114
+ return [
115
+ "## Failing request",
116
+ `- **Endpoint**: \`${request.path}\``,
117
+ `- **Method**: ${request.method}`,
118
+ `- **Status**: ${status}`,
119
+ `- **Correlation id**: ${request.correlationId ?? "(none)"}`,
120
+ "",
121
+ ];
122
+ }
123
+
124
+ function requestsSection(requests: readonly RequestBreadcrumb[]): string[] {
125
+ if (requests.length === 0) {
126
+ return ["## Recent API requests", "(none)", ""];
127
+ }
128
+ const rows = requests.map((r) => {
129
+ const status = r.status === 0 ? "ERR" : `${r.status}`;
130
+ return `| ${clockTime(r.timestamp)} | ${r.method} | \`${r.path}\` | ${status} | ${r.durationMs}ms | ${r.correlationId ?? ""} |`;
131
+ });
132
+ return [
133
+ "## Recent API requests",
134
+ "| Time | Method | Path | Status | Duration | Correlation |",
135
+ "| --- | --- | --- | --- | --- | --- |",
136
+ ...rows,
137
+ "",
138
+ ];
139
+ }
140
+
141
+ function navigationSection(routes: readonly RouteBreadcrumb[]): string[] {
142
+ if (routes.length === 0) {
143
+ return ["## Navigation trail", "(none)", ""];
144
+ }
145
+ const rows = routes.map((r) => `- ${clockTime(r.timestamp)} \`${r.path}\``);
146
+ return ["## Navigation trail", ...rows, ""];
147
+ }
148
+
80
149
  interface BodyOverrides {
81
150
  stack?: string;
82
151
  /** Replaces the MIME-tree section only; it is fenced after this is applied. */
@@ -94,7 +163,7 @@ function buildIssueBody(
94
163
 
95
164
  const lines: string[] = [
96
165
  "## Environment",
97
- `- **Version**: [\`${ctx.appShortSha}\`](https://github.com/remit-mail/reader/commit/${ctx.appSha}) built ${ctx.appBuildTime}`,
166
+ versionLine(ctx),
98
167
  `- **Browser**: ${ctx.userAgent}`,
99
168
  `- **Viewport**: ${ctx.viewport}`,
100
169
  `- **Time**: ${ctx.timestamp} (${ctx.timezone})`,
@@ -108,6 +177,10 @@ function buildIssueBody(
108
177
  lines.push("## Error", ctx.errorMessage, "");
109
178
  }
110
179
 
180
+ if (ctx.failingRequest) {
181
+ lines.push(...failingRequestSection(ctx.failingRequest));
182
+ }
183
+
111
184
  if (ctx.quarantine) {
112
185
  const structure =
113
186
  overrides?.quarantineStructure ?? ctx.quarantine.structure;
@@ -130,6 +203,9 @@ function buildIssueBody(
130
203
  lines.push("## Component stack", fencedBlock(ctx.componentStack), "");
131
204
  }
132
205
 
206
+ lines.push(...requestsSection(ctx.requests));
207
+ lines.push(...navigationSection(ctx.routes));
208
+
133
209
  lines.push(
134
210
  "## Recent console errors",
135
211
  errorSection,
@@ -15,6 +15,8 @@
15
15
  * boundary makes the question decidable and the browser's wording irrelevant.
16
16
  */
17
17
 
18
+ import { recordRequest } from "./request-breadcrumbs";
19
+
18
20
  /** A request that never reached a server. Always soft — never escalated. */
19
21
  export class NetworkError extends Error {
20
22
  constructor(cause: unknown) {
@@ -47,12 +49,26 @@ const isDeliberateAbort = (error: unknown): boolean =>
47
49
  * `fetch`, with transport failures tagged. Every app-owned request goes through
48
50
  * this — the generated client is configured with it, and the hand-written
49
51
  * wrapper calls it — so a `NetworkError` downstream is a fact, not an inference.
52
+ *
53
+ * It is also the one place every request is seen, so it records a metadata-only
54
+ * breadcrumb (method, path, status, duration, correlation id) for bug reports.
55
+ * A deliberate abort is not a request outcome worth reporting, so it is
56
+ * re-thrown before any breadcrumb is written.
50
57
  */
51
58
  export const taggedFetch: typeof fetch = async (input, init) => {
59
+ const startedAt = performance.now();
52
60
  try {
53
- return await fetch(input, init);
61
+ const response = await fetch(input, init);
62
+ recordRequest({
63
+ input,
64
+ init,
65
+ response,
66
+ durationMs: performance.now() - startedAt,
67
+ });
68
+ return response;
54
69
  } catch (error) {
55
70
  if (isDeliberateAbort(error)) throw error;
71
+ recordRequest({ input, init, durationMs: performance.now() - startedAt });
56
72
  throw new NetworkError(error);
57
73
  }
58
74
  };
@@ -0,0 +1,162 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import {
4
+ __resetRequestBreadcrumbs,
5
+ getFailingRequest,
6
+ getRecentRequests,
7
+ recordRequest,
8
+ requestPath,
9
+ } from "./request-breadcrumbs";
10
+
11
+ const response = (
12
+ status: number,
13
+ headers: Record<string, string> = {},
14
+ ): Response => new Response(null, { status, headers });
15
+
16
+ beforeEach(() => {
17
+ __resetRequestBreadcrumbs();
18
+ });
19
+
20
+ describe("requestPath — redaction boundary", () => {
21
+ it("keeps the pathname and drops the query string", () => {
22
+ assert.equal(requestPath("/api/search?q=secret+query+text"), "/api/search");
23
+ });
24
+
25
+ it("drops the query on an absolute URL too", () => {
26
+ assert.equal(
27
+ requestPath("https://app.example.com/api/messages?q=private"),
28
+ "/api/messages",
29
+ );
30
+ });
31
+
32
+ it("drops a hash fragment", () => {
33
+ assert.equal(requestPath("/api/thing#section"), "/api/thing");
34
+ });
35
+
36
+ it("keeps opaque path ids (already public in the URL section)", () => {
37
+ assert.equal(
38
+ requestPath("/api/messages/abc-123-def"),
39
+ "/api/messages/abc-123-def",
40
+ );
41
+ });
42
+
43
+ it("accepts a URL instance", () => {
44
+ assert.equal(
45
+ requestPath(new URL("https://app.example.com/api/x?q=1")),
46
+ "/api/x",
47
+ );
48
+ });
49
+
50
+ it("accepts a Request instance", () => {
51
+ assert.equal(
52
+ requestPath(new Request("https://app.example.com/api/y?token=abc")),
53
+ "/api/y",
54
+ );
55
+ });
56
+ });
57
+
58
+ describe("recordRequest", () => {
59
+ it("captures metadata only — never the query or the body", () => {
60
+ recordRequest({
61
+ input: "/api/search?q=confidential",
62
+ init: { method: "POST", body: JSON.stringify({ subject: "secret" }) },
63
+ response: response(200),
64
+ durationMs: 12.6,
65
+ });
66
+ const [entry] = getRecentRequests();
67
+ assert.equal(entry.method, "POST");
68
+ assert.equal(entry.path, "/api/search");
69
+ assert.equal(entry.status, 200);
70
+ assert.equal(entry.durationMs, 13);
71
+ // The breadcrumb shape has no body/query field; assert nothing leaked in.
72
+ const serialized = JSON.stringify(entry);
73
+ assert.ok(
74
+ !serialized.includes("confidential"),
75
+ "query text must not be captured",
76
+ );
77
+ assert.ok(
78
+ !serialized.includes("secret"),
79
+ "request body must not be captured",
80
+ );
81
+ });
82
+
83
+ it("defaults the method to GET", () => {
84
+ recordRequest({
85
+ input: "/api/thing",
86
+ response: response(204),
87
+ durationMs: 1,
88
+ });
89
+ assert.equal(getRecentRequests()[0].method, "GET");
90
+ });
91
+
92
+ it("reads the method from a Request instance", () => {
93
+ recordRequest({
94
+ input: new Request("https://app.example.com/api/z", { method: "delete" }),
95
+ response: response(200),
96
+ durationMs: 1,
97
+ });
98
+ assert.equal(getRecentRequests()[0].method, "DELETE");
99
+ });
100
+
101
+ it("records status 0 and no correlation id for a transport failure", () => {
102
+ recordRequest({ input: "/api/thing", durationMs: 5000 });
103
+ const [entry] = getRecentRequests();
104
+ assert.equal(entry.status, 0);
105
+ assert.equal(entry.correlationId, undefined);
106
+ });
107
+
108
+ it("reads the correlation id from the first present header", () => {
109
+ recordRequest({
110
+ input: "/api/a",
111
+ response: response(200, { "x-request-id": "req-42" }),
112
+ durationMs: 1,
113
+ });
114
+ assert.equal(getRecentRequests()[0].correlationId, "req-42");
115
+ });
116
+
117
+ it("keeps only the last ten requests", () => {
118
+ for (let i = 0; i < 14; i++) {
119
+ recordRequest({
120
+ input: `/api/n/${i}`,
121
+ response: response(200),
122
+ durationMs: 1,
123
+ });
124
+ }
125
+ const entries = getRecentRequests();
126
+ assert.equal(entries.length, 10);
127
+ assert.equal(entries[0].path, "/api/n/4");
128
+ assert.equal(entries[9].path, "/api/n/13");
129
+ });
130
+ });
131
+
132
+ describe("getFailingRequest", () => {
133
+ it("returns the most recent HTTP error", () => {
134
+ recordRequest({ input: "/api/ok", response: response(200), durationMs: 1 });
135
+ recordRequest({
136
+ input: "/api/bad",
137
+ response: response(500),
138
+ durationMs: 1,
139
+ });
140
+ recordRequest({
141
+ input: "/api/ok2",
142
+ response: response(200),
143
+ durationMs: 1,
144
+ });
145
+ assert.equal(getFailingRequest()?.path, "/api/bad");
146
+ });
147
+
148
+ it("treats a transport failure as failing", () => {
149
+ recordRequest({ input: "/api/gone", durationMs: 100 });
150
+ assert.equal(getFailingRequest()?.status, 0);
151
+ });
152
+
153
+ it("returns undefined when every request succeeded", () => {
154
+ recordRequest({ input: "/api/ok", response: response(200), durationMs: 1 });
155
+ recordRequest({
156
+ input: "/api/ok2",
157
+ response: response(304),
158
+ durationMs: 1,
159
+ });
160
+ assert.equal(getFailingRequest(), undefined);
161
+ });
162
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * A constant-size ring of the last few API calls, captured at the one fetch
3
+ * choke point (`taggedFetch` in network-error.ts). It gives a bug report the
4
+ * request context that a minified stack cannot: which endpoints were hit, in
5
+ * what order, and what they returned — plus the correlation id that ties a
6
+ * failing request to its server-side log line.
7
+ *
8
+ * PRIVACY — the issue tracker is public, so a breadcrumb is METADATA ONLY.
9
+ * Capture records the request method, the URL PATHNAME, the HTTP status, the
10
+ * duration, and a correlation id. It never records a request or response body,
11
+ * and never the query string — a search's `?q=` carries the user's query text,
12
+ * a message fetch's params can carry a subject. `requestPath` strips the query
13
+ * and hash precisely so none of that can reach the report even when the fetch
14
+ * layer has the full URL in hand. Opaque path ids (a message id in the path)
15
+ * are acceptable: they already appear in the report's URL section.
16
+ *
17
+ * Capture is cheap by construction — a fixed-size array of small records, no
18
+ * serialization until a report is assembled.
19
+ */
20
+
21
+ export interface RequestBreadcrumb {
22
+ method: string;
23
+ /** URL pathname only — never the query string or hash (see file header). */
24
+ path: string;
25
+ /** HTTP status, or 0 when the request never reached a server (transport failure). */
26
+ status: number;
27
+ durationMs: number;
28
+ /** Server correlation id from a response header, when the response carried one. */
29
+ correlationId?: string;
30
+ timestamp: string;
31
+ }
32
+
33
+ const MAX_ENTRIES = 10;
34
+
35
+ const ring: RequestBreadcrumb[] = [];
36
+
37
+ /**
38
+ * Response headers that carry a request/trace correlation id, in preference
39
+ * order. `Headers.get` is case-insensitive, so the lowercase form matches
40
+ * whatever casing the server sent.
41
+ */
42
+ const CORRELATION_HEADERS = [
43
+ "x-correlation-id",
44
+ "x-request-id",
45
+ "x-amzn-requestid",
46
+ "x-amzn-trace-id",
47
+ "apigw-requestid",
48
+ "x-trace-id",
49
+ ] as const;
50
+
51
+ /**
52
+ * The pathname of a request, with the query string and hash discarded. This is
53
+ * the redaction boundary: it is the only thing that reads the request URL, and
54
+ * it deliberately keeps nothing but the path so no query text can leak into a
55
+ * breadcrumb.
56
+ */
57
+ export function requestPath(input: RequestInfo | URL): string {
58
+ const raw =
59
+ typeof input === "string"
60
+ ? input
61
+ : input instanceof URL
62
+ ? input.href
63
+ : input.url;
64
+ try {
65
+ return new URL(raw, window.location.origin).pathname;
66
+ } catch {
67
+ // A non-URL input (rare) still must not leak a query — cut at the first
68
+ // `?` or `#` and return what precedes it.
69
+ return raw.split(/[?#]/)[0];
70
+ }
71
+ }
72
+
73
+ function requestMethod(input: RequestInfo | URL, init?: RequestInit): string {
74
+ if (init?.method) return init.method.toUpperCase();
75
+ if (typeof input !== "string" && !(input instanceof URL) && input.method) {
76
+ return input.method.toUpperCase();
77
+ }
78
+ return "GET";
79
+ }
80
+
81
+ function correlationIdFrom(response: Response): string | undefined {
82
+ for (const header of CORRELATION_HEADERS) {
83
+ const value = response.headers.get(header);
84
+ if (value) return value;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ function push(entry: RequestBreadcrumb): void {
90
+ ring.push(entry);
91
+ if (ring.length > MAX_ENTRIES) ring.shift();
92
+ }
93
+
94
+ /**
95
+ * Record the outcome of one fetch. Called from `taggedFetch` for both a
96
+ * completed response and a transport failure (no response, status 0). Only the
97
+ * metadata listed in `RequestBreadcrumb` is read — never `init.body` or the
98
+ * response body.
99
+ */
100
+ export function recordRequest(args: {
101
+ input: RequestInfo | URL;
102
+ init?: RequestInit;
103
+ response?: Response;
104
+ durationMs: number;
105
+ }): void {
106
+ const { input, init, response, durationMs } = args;
107
+ push({
108
+ method: requestMethod(input, init),
109
+ path: requestPath(input),
110
+ status: response?.status ?? 0,
111
+ durationMs: Math.round(durationMs),
112
+ correlationId: response ? correlationIdFrom(response) : undefined,
113
+ timestamp: new Date().toISOString(),
114
+ });
115
+ }
116
+
117
+ /** The captured breadcrumbs, oldest first. A copy — callers cannot mutate the ring. */
118
+ export function getRecentRequests(): readonly RequestBreadcrumb[] {
119
+ return ring.slice();
120
+ }
121
+
122
+ /**
123
+ * The most recent request that failed — a transport failure (status 0) or an
124
+ * HTTP error (>= 400). This is the request a report should call out explicitly,
125
+ * so nobody has to decode a minified stack to learn what broke.
126
+ */
127
+ export function getFailingRequest(): RequestBreadcrumb | undefined {
128
+ for (let i = ring.length - 1; i >= 0; i--) {
129
+ const entry = ring[i];
130
+ if (entry.status === 0 || entry.status >= 400) return entry;
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ /** Test-only: clear the ring. */
136
+ export function __resetRequestBreadcrumbs(): void {
137
+ ring.length = 0;
138
+ }
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import {
4
+ __resetRouteBreadcrumbs,
5
+ getRecentRoutes,
6
+ recordRoute,
7
+ } from "./route-breadcrumbs";
8
+
9
+ beforeEach(() => {
10
+ __resetRouteBreadcrumbs();
11
+ });
12
+
13
+ describe("recordRoute", () => {
14
+ it("captures the pathname with a timestamp", () => {
15
+ recordRoute("/mail/inbox");
16
+ const [entry] = getRecentRoutes();
17
+ assert.equal(entry.path, "/mail/inbox");
18
+ assert.ok(entry.timestamp);
19
+ });
20
+
21
+ it("strips a query string — never the user's search text", () => {
22
+ recordRoute("/mail/search?q=confidential");
23
+ const [entry] = getRecentRoutes();
24
+ assert.equal(entry.path, "/mail/search");
25
+ assert.ok(!JSON.stringify(entry).includes("confidential"));
26
+ });
27
+
28
+ it("collapses a repeated navigation to the same path", () => {
29
+ recordRoute("/mail/inbox");
30
+ recordRoute("/mail/inbox");
31
+ assert.equal(getRecentRoutes().length, 1);
32
+ });
33
+
34
+ it("keeps only the last eight navigations", () => {
35
+ for (let i = 0; i < 12; i++) recordRoute(`/mail/${i}`);
36
+ const entries = getRecentRoutes();
37
+ assert.equal(entries.length, 8);
38
+ assert.equal(entries[0].path, "/mail/4");
39
+ assert.equal(entries[7].path, "/mail/11");
40
+ });
41
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A constant-size ring of the last few in-app navigations, captured from the
3
+ * router's `onResolved` event (see router.tsx). It gives a bug report a
4
+ * navigation trail, so "Steps to reproduce" has something concrete even when
5
+ * the reporter leaves the template blank.
6
+ *
7
+ * PRIVACY — like request breadcrumbs, this is METADATA ONLY and the tracker is
8
+ * public. A route entry is the resolved PATHNAME and a timestamp. The query
9
+ * string is dropped: a search route's `?q=` carries the user's query text.
10
+ * Opaque path ids (a message id in the path) are acceptable — they already
11
+ * appear in the report's URL section.
12
+ */
13
+
14
+ export interface RouteBreadcrumb {
15
+ /** Resolved pathname only — never the query string (see file header). */
16
+ path: string;
17
+ timestamp: string;
18
+ }
19
+
20
+ const MAX_ENTRIES = 8;
21
+
22
+ const ring: RouteBreadcrumb[] = [];
23
+
24
+ /**
25
+ * Record a navigation. `path` is expected to be a pathname; any query or hash
26
+ * is stripped defensively so a caller passing a full location cannot leak query
27
+ * text into a breadcrumb.
28
+ */
29
+ export function recordRoute(path: string): void {
30
+ const clean = path.split(/[?#]/)[0];
31
+ const last = ring[ring.length - 1];
32
+ if (last && last.path === clean) return;
33
+ ring.push({ path: clean, timestamp: new Date().toISOString() });
34
+ if (ring.length > MAX_ENTRIES) ring.shift();
35
+ }
36
+
37
+ /** The captured navigations, oldest first. A copy — callers cannot mutate the ring. */
38
+ export function getRecentRoutes(): readonly RouteBreadcrumb[] {
39
+ return ring.slice();
40
+ }
41
+
42
+ /** Test-only: clear the ring. */
43
+ export function __resetRouteBreadcrumbs(): void {
44
+ ring.length = 0;
45
+ }
package/src/router.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { QueryClient } from "@tanstack/react-query";
2
2
  import { createRouter } from "@tanstack/react-router";
3
+ import { recordRoute } from "./lib/route-breadcrumbs";
3
4
  import type { Telemetry } from "./lib/telemetry";
4
5
  import { routeTree } from "./routeTree.gen";
5
6
 
@@ -19,6 +20,9 @@ export const createAppRouter = (
19
20
 
20
21
  router.subscribe("onResolved", (event) => {
21
22
  telemetry.recordPageView(event.toLocation.pathname);
23
+ // A metadata-only navigation trail for bug reports; pathname only, never
24
+ // the query string (route-breadcrumbs.ts enforces the redaction).
25
+ recordRoute(event.toLocation.pathname);
22
26
  });
23
27
 
24
28
  return router;