@indigoai-us/hq-cli 5.55.0 → 5.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.55.0",
3
+ "version": "5.57.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -82,6 +82,7 @@ function makeShareSpy(
82
82
  bytesUploaded: 0,
83
83
  filesSkipped: 0,
84
84
  filesDeleted: 0,
85
+ filesExcludedByScope: 0,
85
86
  conflictPaths: [],
86
87
  aborted: false,
87
88
  ...override,
@@ -307,6 +308,7 @@ describe("pushAll", () => {
307
308
  filesUploaded: 0,
308
309
  bytesUploaded: 0,
309
310
  filesDeleted: 0,
311
+ filesExcludedByScope: 0,
310
312
  errors: [],
311
313
  perCompany: [],
312
314
  });
@@ -379,6 +381,33 @@ describe("pushAll", () => {
379
381
  expect(result.filesDeleted).toBe(4);
380
382
  });
381
383
 
384
+ it("aggregates filesExcludedByScope across every successful target", async () => {
385
+ const hqRoot = makeHqRoot(["companies", "docs"]);
386
+ const vaultClient = makeVaultClient({
387
+ memberships: [{ companyUid: "cmp_acme" }],
388
+ persons: [
389
+ {
390
+ uid: "psn_alice",
391
+ type: "person",
392
+ slug: "alice",
393
+ createdAt: "2026-01-01T00:00:00Z",
394
+ },
395
+ ],
396
+ entitiesBySlug: { cmp_acme: { slug: "acme" } },
397
+ });
398
+ const share = makeShareSpy({
399
+ cmp_acme: { filesExcludedByScope: 2 },
400
+ psn_alice: { filesExcludedByScope: 3 },
401
+ });
402
+
403
+ const result = await pushAll(
404
+ { hqRoot },
405
+ { vaultClient, share: share.fn },
406
+ );
407
+
408
+ expect(result.filesExcludedByScope).toBe(5);
409
+ });
410
+
382
411
  // ── skipPersonal — fanout omits the canonical-person leg ──────────────────
383
412
  //
384
413
  // Symmetric with the `pullAll` skipPersonal coverage in
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { scopeExcludedWarning } from "./cloud.js";
4
+
5
+ describe("scopeExcludedWarning", () => {
6
+ it("returns null for zero and negative counts", () => {
7
+ expect(scopeExcludedWarning(0)).toBeNull();
8
+ expect(scopeExcludedWarning(-1)).toBeNull();
9
+ });
10
+
11
+ it("returns a loud warning for positive counts", () => {
12
+ const warning = scopeExcludedWarning(3);
13
+
14
+ expect(warning).toBeTruthy();
15
+ expect(warning).toContain("3");
16
+ // The warning must loudly state the files did NOT reach the vault and
17
+ // explain why (outside the caller's write access) — without leaning on
18
+ // the jargon word "scope" in user-facing copy.
19
+ expect(warning).toContain("NOT uploaded");
20
+ expect(warning?.toLowerCase()).toContain("write access");
21
+ });
22
+ });
@@ -50,6 +50,22 @@ import {
50
50
  type BannerLevel,
51
51
  } from "../lib/narrow-hint-banner.js";
52
52
 
53
+ /**
54
+ * Build a loud, human-readable warning when a push dropped files because
55
+ * they fell outside the caller's granted write scope. Returns null when
56
+ * nothing was scope-excluded. Keeping this pure makes the "never silently
57
+ * succeed when files were dropped" guarantee unit-testable.
58
+ */
59
+ export function scopeExcludedWarning(count: number): string | null {
60
+ if (count <= 0) return null;
61
+ return (
62
+ `⚠ ${count} file(s) were NOT uploaded — they fall outside the ` +
63
+ `prefixes you have write access to (company-wide or direct grants). ` +
64
+ `They were skipped, not synced. Re-run with --json to list them, or ` +
65
+ `ask an admin to grant you write on those paths.`
66
+ );
67
+ }
68
+
53
69
  /**
54
70
  * Resolve the `propagateDeletePolicy` for share() calls.
55
71
  *
@@ -234,6 +250,7 @@ export interface ShareCallResult {
234
250
  bytesUploaded: number;
235
251
  filesSkipped: number;
236
252
  filesDeleted: number;
253
+ filesExcludedByScope: number;
237
254
  conflictPaths: string[];
238
255
  aborted: boolean;
239
256
  }
@@ -267,6 +284,7 @@ export interface PushAllResult {
267
284
  filesUploaded: number;
268
285
  bytesUploaded: number;
269
286
  filesDeleted: number;
287
+ filesExcludedByScope: number;
270
288
  errors: Array<{ company: string; message: string }>;
271
289
  perCompany: PushAllRow[];
272
290
  }
@@ -513,6 +531,7 @@ export async function pushAll(
513
531
  filesUploaded: 0,
514
532
  bytesUploaded: 0,
515
533
  filesDeleted: 0,
534
+ filesExcludedByScope: 0,
516
535
  errors: [],
517
536
  perCompany: [],
518
537
  };
@@ -524,6 +543,7 @@ export async function pushAll(
524
543
  result.filesUploaded += r.filesUploaded;
525
544
  result.bytesUploaded += r.bytesUploaded;
526
545
  result.filesDeleted += r.filesDeleted;
546
+ result.filesExcludedByScope += r.filesExcludedByScope;
527
547
  result.perCompany.push({ slug: entry.slug, result: r });
528
548
  } catch (err) {
529
549
  const message = err instanceof Error ? err.message : String(err);
@@ -1016,11 +1036,21 @@ export function registerCloudCommands(program: Command): void {
1016
1036
  process.exit(1);
1017
1037
  }
1018
1038
 
1019
- log(
1020
- chalk.green(
1021
- `\n✓ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ${result.filesSkipped} skipped)`,
1022
- ),
1023
- );
1039
+ if (result.filesExcludedByScope > 0) {
1040
+ log(
1041
+ chalk.yellow(
1042
+ `\n⚠ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ` +
1043
+ `${result.filesSkipped} skipped, ${result.filesExcludedByScope} scope-excluded)`,
1044
+ ),
1045
+ );
1046
+ log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)!));
1047
+ } else {
1048
+ log(
1049
+ chalk.green(
1050
+ `\n✓ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ${result.filesSkipped} skipped)`,
1051
+ ),
1052
+ );
1053
+ }
1024
1054
  } catch (err) {
1025
1055
  const message = err instanceof Error ? err.message : String(err);
1026
1056
  if (jsonMode) {
@@ -1657,11 +1687,15 @@ async function runPushAll(
1657
1687
  console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
1658
1688
  } else if (row.result) {
1659
1689
  const r = row.result;
1660
- const status = r.aborted ? chalk.yellow("⚠") : chalk.green("✓");
1690
+ const status =
1691
+ r.aborted || r.filesExcludedByScope > 0
1692
+ ? chalk.yellow("⚠")
1693
+ : chalk.green("✓");
1661
1694
  console.log(
1662
1695
  ` ${status} ${row.slug}: ${r.filesUploaded} file(s), ` +
1663
1696
  `${formatBytes(r.bytesUploaded)}, ${r.filesSkipped} skipped, ` +
1664
- `${r.filesDeleted} deleted, ${r.conflictPaths.length} conflict(s)` +
1697
+ `${r.filesDeleted} deleted, ${r.filesExcludedByScope} scope-excluded, ` +
1698
+ `${r.conflictPaths.length} conflict(s)` +
1665
1699
  (r.aborted ? " — aborted" : ""),
1666
1700
  );
1667
1701
  }
@@ -1671,8 +1705,16 @@ async function runPushAll(
1671
1705
  const summary =
1672
1706
  `\nPushed ${result.filesUploaded} file(s) ` +
1673
1707
  `(${formatBytes(result.bytesUploaded)}) across ${result.attempted} ` +
1674
- `target(s); ${result.filesDeleted} deleted; ${errored} error(s)`;
1675
- console.log(errored > 0 ? chalk.yellow(summary) : chalk.green(summary));
1708
+ `target(s); ${result.filesDeleted} deleted; ${errored} error(s); ` +
1709
+ `${result.filesExcludedByScope} scope-excluded`;
1710
+ console.log(
1711
+ errored > 0 || result.filesExcludedByScope > 0
1712
+ ? chalk.yellow(summary)
1713
+ : chalk.green(summary),
1714
+ );
1715
+ if (result.filesExcludedByScope > 0) {
1716
+ console.log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)!));
1717
+ }
1676
1718
  if (errored > 0) process.exit(1);
1677
1719
  }
1678
1720
 
@@ -1747,12 +1789,19 @@ async function runNowSingle(
1747
1789
  ...(journalSlug !== undefined ? { journalSlug } : {}),
1748
1790
  ...(author ? { author } : {}),
1749
1791
  });
1792
+ const pushStatus =
1793
+ pushResult.aborted || pushResult.filesExcludedByScope > 0
1794
+ ? chalk.yellow("⚠")
1795
+ : chalk.green("✓");
1750
1796
  console.log(
1751
- ` ${pushResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
1797
+ ` ${pushStatus} ` +
1752
1798
  `${pushResult.filesUploaded} uploaded, ${pushResult.filesSkipped} skipped, ` +
1753
- `${pushResult.filesDeleted} deleted` +
1799
+ `${pushResult.filesDeleted} deleted, ${pushResult.filesExcludedByScope} scope-excluded` +
1754
1800
  (pushResult.aborted ? " — aborted" : ""),
1755
1801
  );
1802
+ if (pushResult.filesExcludedByScope > 0) {
1803
+ console.log(chalk.yellow(scopeExcludedWarning(pushResult.filesExcludedByScope)!));
1804
+ }
1756
1805
  if (pushResult.aborted) {
1757
1806
  console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
1758
1807
  process.exit(1);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Unit tests for `hq groups` principal detection (groups.ts).
3
+ *
4
+ * Regression coverage for feedback_edb1796a-24be-4b9f-bc60-a06f39d1c9f2:
5
+ * `hq groups add` rejected agent identities (agt_<ULID>), forcing per-agent
6
+ * secret shares instead of group-carried ACLs. Agent uids ride the same
7
+ * personUid wire slot as people (server contract — the group-members handler
8
+ * accepts granteeType "person" with an agt_* granteeId, and every ACL
9
+ * evaluation keys off caller.personUid, which for agent JWTs IS the agt_ uid).
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import { detectPrincipalType } from "./groups.js";
15
+
16
+ describe("detectPrincipalType", () => {
17
+ it("detects an email principal and normalizes it", () => {
18
+ expect(detectPrincipalType("Jane@Example.COM")).toEqual({
19
+ granteeType: "email",
20
+ granteeId: "jane@example.com",
21
+ });
22
+ });
23
+
24
+ it("detects a personUid principal", () => {
25
+ expect(detectPrincipalType("prs_01ABCDEF")).toEqual({
26
+ granteeType: "person",
27
+ granteeId: "prs_01ABCDEF",
28
+ });
29
+ });
30
+
31
+ it("detects an agentUid principal and sends it in the person slot (regression)", () => {
32
+ expect(detectPrincipalType("agt_01KWFVWZYX8H1DN57ZPNZNKM6C")).toEqual({
33
+ granteeType: "person",
34
+ granteeId: "agt_01KWFVWZYX8H1DN57ZPNZNKM6C",
35
+ });
36
+ });
37
+
38
+ it("rejects malformed principals", () => {
39
+ expect(detectPrincipalType("agt_")).toBeNull();
40
+ expect(detectPrincipalType("prs_")).toBeNull();
41
+ expect(detectPrincipalType("grp_finance")).toBeNull();
42
+ expect(detectPrincipalType("not a principal")).toBeNull();
43
+ });
44
+ });
@@ -6,8 +6,12 @@ import { GROUP_ID_PATTERN } from "./_patterns.js";
6
6
 
7
7
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
8
8
  const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
9
+ const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
9
10
 
10
- function detectPrincipalType(
11
+ const INVALID_PRINCIPAL_HINT =
12
+ "must be an email address, a personUid (prs_…), or an agentUid (agt_…)";
13
+
14
+ export function detectPrincipalType(
11
15
  principal: string,
12
16
  ): { granteeType: "email" | "person"; granteeId: string } | null {
13
17
  if (EMAIL_PATTERN.test(principal)) {
@@ -15,7 +19,10 @@ function detectPrincipalType(
15
19
  // cache keys agree with the server-side canonicalization.
16
20
  return { granteeType: "email", granteeId: principal.trim().toLowerCase() };
17
21
  }
18
- if (PERSON_UID_PATTERN.test(principal)) {
22
+ // Agent uids ride the same personUid wire slot as people — that is the
23
+ // server contract (group members, DM recipients, and memberships all carry
24
+ // agt_* in the personUid field; see hq-pro handlers).
25
+ if (PERSON_UID_PATTERN.test(principal) || AGENT_UID_PATTERN.test(principal)) {
19
26
  return { granteeType: "person", granteeId: principal };
20
27
  }
21
28
  return null;
@@ -130,7 +137,7 @@ export function registerGroupsCommand(program: Command): void {
130
137
 
131
138
  groups
132
139
  .command("add <groupId> <principal>")
133
- .description("Add a person to a group (principal: email or personUid)")
140
+ .description("Add a person or agent to a group (principal: email, personUid, or agentUid)")
134
141
  .action(async (groupId: string, principal: string) => {
135
142
  try {
136
143
  if (!GROUP_ID_PATTERN.test(groupId)) {
@@ -140,7 +147,7 @@ export function registerGroupsCommand(program: Command): void {
140
147
 
141
148
  const detected = detectPrincipalType(principal);
142
149
  if (!detected) {
143
- console.error(chalk.red(`Invalid principal '${principal}': must be an email address or a personUid matching prs_<alphanumeric>`));
150
+ console.error(chalk.red(`Invalid principal '${principal}': ${INVALID_PRINCIPAL_HINT}`));
144
151
  process.exit(1);
145
152
  }
146
153
 
@@ -181,7 +188,7 @@ export function registerGroupsCommand(program: Command): void {
181
188
 
182
189
  groups
183
190
  .command("remove <groupId> <principal>")
184
- .description("Remove a person from a group (principal: email or personUid)")
191
+ .description("Remove a person or agent from a group (principal: email, personUid, or agentUid)")
185
192
  .action(async (groupId: string, principal: string) => {
186
193
  try {
187
194
  if (!GROUP_ID_PATTERN.test(groupId)) {
@@ -191,7 +198,7 @@ export function registerGroupsCommand(program: Command): void {
191
198
 
192
199
  const detected = detectPrincipalType(principal);
193
200
  if (!detected) {
194
- console.error(chalk.red(`Invalid principal '${principal}': must be an email address or a personUid matching prs_<alphanumeric>`));
201
+ console.error(chalk.red(`Invalid principal '${principal}': ${INVALID_PRINCIPAL_HINT}`));
195
202
  process.exit(1);
196
203
  }
197
204
 
@@ -65,6 +65,85 @@ function jsonRes(body: unknown, status = 200): Response {
65
65
  });
66
66
  }
67
67
 
68
+ describe("meetings get — short id resolution", () => {
69
+ it("resolves an 8-char short id to the full id before calling the by-id endpoint", async () => {
70
+ const fullId = "589bfd78-aaaa-bbbb-cccc-1234567890ab";
71
+ vi.mocked(vaultApiFetch)
72
+ .mockResolvedValueOnce(
73
+ jsonRes({
74
+ meetings: [
75
+ {
76
+ meetingId: fullId,
77
+ title: "T",
78
+ status: "ready",
79
+ startTime: "2026-01-01T00:00:00Z",
80
+ endTime: "2026-01-01T00:00:00Z",
81
+ duration: 0,
82
+ participantCount: 0,
83
+ hasTranscript: false,
84
+ hasNotes: false,
85
+ },
86
+ ],
87
+ }),
88
+ )
89
+ .mockResolvedValueOnce(
90
+ jsonRes({
91
+ meetingId: fullId,
92
+ title: "T",
93
+ startTime: "2026-01-01T00:00:00Z",
94
+ endTime: "2026-01-01T00:00:00Z",
95
+ duration: 0,
96
+ participants: [],
97
+ calendarEventId: null,
98
+ botProvider: "recall",
99
+ sourceApp: "calendar",
100
+ companyId: "cmp_acme",
101
+ status: "ready",
102
+ recallBotId: null,
103
+ isShared: false,
104
+ createdAt: "2026-01-01T00:00:00Z",
105
+ updatedAt: "2026-01-01T00:00:00Z",
106
+ documentUrl: "",
107
+ hasTranscript: false,
108
+ hasNotes: false,
109
+ }),
110
+ );
111
+
112
+ const program = buildProgram();
113
+ await program.parseAsync(["node", "hq", "meetings", "get", "589bfd78"]);
114
+
115
+ expect(vaultApiFetch).toHaveBeenNthCalledWith(1, {
116
+ token: "test-token",
117
+ path: "/v1/meetings",
118
+ query: { limit: "500" },
119
+ });
120
+ expect(vaultApiFetch).toHaveBeenNthCalledWith(2, {
121
+ token: "test-token",
122
+ path: `/v1/meetings/${fullId}`,
123
+ query: {},
124
+ });
125
+ });
126
+
127
+ it("errors and never sends the truncated id when the short id matches no meeting", async () => {
128
+ vi.spyOn(process, "exit").mockImplementation((() => {
129
+ throw new Error("process.exit");
130
+ }) as never);
131
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ meetings: [] }));
132
+
133
+ const program = buildProgram();
134
+ await expect(
135
+ program.parseAsync(["node", "hq", "meetings", "get", "589bfd78"]),
136
+ ).rejects.toThrow("process.exit");
137
+
138
+ expect(errSpy).toHaveBeenCalledWith(
139
+ expect.stringContaining("No meeting matches ID \"589bfd78\""),
140
+ );
141
+ expect(vaultApiFetch).not.toHaveBeenCalledWith(
142
+ expect.objectContaining({ path: "/v1/meetings/589bfd78" }),
143
+ );
144
+ });
145
+ });
146
+
68
147
  describe("meetings set-company", () => {
69
148
  it("POSTs the resolved company id and applies to the recurring series by default", async () => {
70
149
  vi.mocked(vaultApiFetch).mockResolvedValueOnce(
@@ -225,3 +304,49 @@ describe("meetings set-company", () => {
225
304
  expect(output).not.toContain("Future occurrences of this recurring series will inherit this attribution.");
226
305
  });
227
306
  });
307
+
308
+ describe("meetings list — null-safe rendering", () => {
309
+ it("does not crash when a meeting has an undefined or null title", async () => {
310
+ // The API can return meetings with a missing/null title even though the
311
+ // type says string. This list previously crashed with
312
+ // "Cannot read properties of undefined (reading 'length')" after printing
313
+ // the header. Assert the command completes and renders a placeholder.
314
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
315
+ jsonRes({
316
+ meetings: [
317
+ {
318
+ meetingId: "589bfd78-aaaa-bbbb-cccc-1234567890ab",
319
+ // title intentionally omitted (undefined)
320
+ status: "ready",
321
+ startTime: "2026-01-01T00:00:00Z",
322
+ endTime: "2026-01-01T00:30:00Z",
323
+ duration: 1800,
324
+ participantCount: 3,
325
+ hasTranscript: true,
326
+ hasNotes: false,
327
+ },
328
+ {
329
+ meetingId: "689bfd78-aaaa-bbbb-cccc-1234567890ab",
330
+ title: null,
331
+ status: "ready",
332
+ startTime: "2026-01-02T00:00:00Z",
333
+ endTime: "2026-01-02T00:30:00Z",
334
+ duration: 1800,
335
+ participantCount: 1,
336
+ hasTranscript: false,
337
+ hasNotes: true,
338
+ },
339
+ ],
340
+ }),
341
+ );
342
+
343
+ const program = buildProgram();
344
+ await expect(
345
+ program.parseAsync(["node", "hq", "meetings", "list", "--company", "indigo"]),
346
+ ).resolves.toBeDefined();
347
+
348
+ const printed = logSpy.mock.calls.map(([line]) => String(line)).join("\n");
349
+ expect(printed).toContain("Meetings (2)");
350
+ expect(printed).toContain("(untitled)");
351
+ });
352
+ });
@@ -105,8 +105,16 @@ async function resolveShortId(
105
105
  query: Record<string, string>,
106
106
  ): Promise<string> {
107
107
  if (prefix.includes("-") && prefix.length > 8) return prefix;
108
- const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
109
- if (!res.ok) return prefix;
108
+ const res = await vaultApiFetch({
109
+ token,
110
+ path: "/v1/meetings",
111
+ query: { ...query, limit: "500" },
112
+ });
113
+ if (!res.ok) {
114
+ throw new Error(
115
+ `Could not resolve meeting ID "${prefix}" — failed to list meetings (${res.status}). Pass the full meeting id.`,
116
+ );
117
+ }
110
118
  const data = (await res.json()) as { meetings: MeetingListItem[] };
111
119
  const matches = data.meetings.filter((m) => m.meetingId.startsWith(prefix));
112
120
  if (matches.length === 1) return matches[0].meetingId;
@@ -114,7 +122,12 @@ async function resolveShortId(
114
122
  console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.length} meetings. Use a longer prefix.`));
115
123
  process.exit(1);
116
124
  }
117
- return prefix;
125
+ console.error(
126
+ chalk.red(
127
+ `No meeting matches ID "${prefix}". It may be older than the meetings shown by \`hq meetings list\`, still processing, or attributed to a different company. Pass the full meeting id, add --company <slug>, or widen the list with \`hq meetings list --limit <n>\`.`,
128
+ ),
129
+ );
130
+ process.exit(1);
118
131
  }
119
132
 
120
133
  async function handleApiError(res: Response): Promise<never> {
@@ -137,8 +150,13 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
137
150
  return;
138
151
  }
139
152
 
153
+ // Titles can come back null/undefined from the API even though the type says
154
+ // string; fall back to a placeholder so width calc + rendering never crash on
155
+ // `undefined.length`.
156
+ const displayTitle = (m: MeetingListItem): string => m.title ?? "(untitled)";
157
+
140
158
  const ID_W = 8;
141
- const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => m.title.length)));
159
+ const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => displayTitle(m).length)));
142
160
  const DATE_W = 16;
143
161
  const DUR_W = 8;
144
162
  const STATUS_W = 12;
@@ -161,7 +179,8 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
161
179
 
162
180
  for (const m of meetings) {
163
181
  const id = m.meetingId.slice(0, 8);
164
- const title = m.title.length > TITLE_W ? m.title.slice(0, TITLE_W - 1) + "…" : m.title;
182
+ const fullTitle = displayTitle(m);
183
+ const title = fullTitle.length > TITLE_W ? fullTitle.slice(0, TITLE_W - 1) + "…" : fullTitle;
165
184
  const date = new Date(m.startTime).toLocaleDateString("en-US", {
166
185
  month: "short",
167
186
  day: "numeric",