File size: 19,343 Bytes
c1278e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
 
 
 
 
 
 
c1278e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
 
 
 
c1278e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
c1278e4
 
 
f546440
 
 
 
 
 
 
c1278e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
c1278e4
f546440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1278e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
// ---------------------------------------------------------------------------
// alerts/alertsModel.ts β€” WAVE 20 item 25 (contract C-ALERT): the inbox's PURE
// half. React-free and fetch-free, so `verify_alerts.py` runs it under node.
//
// An alert says "tell me when a record ENTERS this view". The client half is
// small, and every part of it fails silently when it is wrong:
//
//   Β· an unread count taken from `items.length` rather than from the server's
//     own `unread` disagrees with the badge the moment a page is capped or a
//     read lands in another tab β€” and a badge that says 3 when the list shows 9
//     teaches the reader to ignore the badge;
//   Β· a notification whose `viewId` no longer resolves must open NOTHING rather
//     than a wrong view β€” alerts outlive the views they were made from;
//   Β· `topic` is the surface's scope key ("customer", "product", "ut_…"), and
//     the ROUTE is a registry key ("customer_data") β€” mapping one to the other
//     by guesswork sends every click to a page that does not exist.
// ---------------------------------------------------------------------------

/**
 * ⭐⭐ WAVE 32 Β· T20 Β· CONTRACT C3 β€” WHERE AN INBOX ITEM OPENS.
 *
 * `module` is the destination surface (`"database"` or `"automation"`) and `id` is what to open
 * in it. `tab` is the SUB-SELECTION inside that module: the literal `"runs"` for an automation's
 * run log, or the VIEW ID to select on a database. One optional key, two destinations.
 *
 * β›” EVERY FIELD IS A PLAIN `string`, NEVER A UNION, and that is alertsModel's wave-9 law rather
 * than laziness: the vocabulary is the SERVER's, and a client union over it turns "the server grew
 * a module" into "the client silently drops the row". An unknown module is refused by the
 * FRAME's dispatcher, out loud, which is a different thing from never arriving.
 */
export interface NotificationTarget {
  module: string;
  id: string;
  tab?: string;
}

/** C3's `kind` vocabulary, mirroring `routes_alerts.NOTIF_KIND_*`. Held as constants so the
 *  Inbox's branch and the gate's fixtures cannot disagree about the word. */
export const NOTIF_KIND_ALERT = "alert";
export const NOTIF_KIND_AUTOMATION = "automation";
export const NOTIF_KIND_SHARE = "share";

/** C3's `target.module` vocabulary, and the automation sub-selection. */
export const TARGET_MODULE_DATABASE = "database";
export const TARGET_MODULE_AUTOMATION = "automation";
export const TARGET_TAB_RUNS = "runs";

/** One notification, as `GET /api/v1/notifications` sends it. */
export interface Notification {
  id: string;
  alertId: string;
  viewId: string;
  topic: string;
  rowId: string;
  /** What entered β€” the record's own label. */
  label: string;
  /** What the alert is called, so a row reads without opening anything. */
  alertLabel: string;
  /** UTC WITH OFFSET (D-18). Kept as the server's STRING: re-formatting it here
   *  would re-introduce the browser-clock drift the offset exists to remove. */
  at: string;
  read: boolean;
  /**
   * WAVE 23 (contract C6) β€” WHAT KIND of notification this is.
   *
   * Absent (and every notification written before this wave) means the original one: a record
   * ENTERED a watched view, routed by `topic` + `viewId`. `"automation_review"` means a card
   * arrived at a review stage and routes by `autoId` instead β€” a different destination reached
   * from the same list.
   *
   * β›” A STRING, NEVER A UNION, and that is the wave-9 law rather than laziness: the vocabulary
   * is the SERVER's, and a client union over it turns "the server grew a kind" into "the client
   * silently drops the row". Unknown kinds fall through to the view route, which is exactly what
   * they did before this field existed.
   */
  kind?: string;
  /** `automation_review` only: the automation whose review stage a card reached. Absent on
   *  every other kind β€” and an `automation_review` row that arrives WITHOUT one opens nothing
   *  rather than guessing, the same posture `viewId` gets. */
  autoId?: string;
  /** Advisory. A surface that does not scroll to a stage simply selects the automation. */
  stageId?: string;
  /** How many cards arrived in the batch. C6 queues ONE notification per run naming the count,
   *  never one per record β€” so this is the number the row's own text is built from. */
  count?: number;
  /**
   * ⭐ WAVE 32 Β· C3 β€” THE HEADER LINE, so the Inbox can be laid out like mail.
   *
   * `subject` is what the item is ABOUT (the alert's name, the automation's name, the database
   * that was shared); `label` stays what HAPPENED (the record that entered, the run summary).
   * They were one field, which is why the pane could only ever render a sentence with no sender.
   * Absent on a server that predates this wave β€” {@link subjectOf} falls back rather than
   * rendering a blank header.
   */
  subject?: string;
  /**
   * ⭐⭐ WAVE 33 Β· W33-T28 β€” WHO IT IS FROM. Mail has a sender; this list did not.
   *
   * A `verifier` reading the finished wave-32 Inbox found the row's sender POSITION occupied by
   * `kindLabel(n.kind)` β€” the literals "Alert" / "Automation" / "Shared with you", a CATEGORY
   * standing where a who belongs β€” and no sender anywhere on the wire. Derived by the server
   * (`routes_alerts.notification_view`), never here: only it knows that a share has a person
   * behind it and an alert has a machine.
   * ⚠ Optional, so a payload from a server that predates this wave keeps today's shape;
   * {@link senderOf} falls back rather than rendering a blank From column.
   */
  sender?: string;
  /** ⭐ WAVE 32 Β· C3 β€” where clicking it goes. ABSENT when this product cannot resolve a
   *  destination (an alert on a table this account can no longer route to), and that absence is
   *  load-bearing: the Inbox renders such a row as plainly unclickable rather than as a click
   *  that silently does nothing. */
  target?: NotificationTarget;
}

/** One alert, as `GET /api/v1/alerts` sends it. */
export interface Alert {
  id: string;
  viewId: string;
  topic: string;
  owner: string;
  label: string;
  createdAt: string;
  /** How many records are in its remembered set right now. */
  matched: number;
  seeded: boolean;
  lastRunAt: string;
  lastError: string;
}

export interface Inbox {
  unread: number;
  items: Notification[];
  /**
   * ⭐⭐ WAVE 33 Β· W33-T28 / D-208 β€” THE SERVER'S CLOCK, so a stamp can be mail-shaped.
   *
   * β›” It exists so {@link stampText} never reads the BROWSER's clock, which is D-208's exit
   * condition word for word. `at` is UTC with its offset (D-18) precisely so every reader sees
   * the same instant; asking "is this today?" of a local clock would put the drift back.
   * ⚠ Optional: absent, `stampText` returns the old absolute string rather than guessing.
   */
  now?: string;
}

export const EMPTY_INBOX: Inbox = { unread: 0, items: [] };

/**
 * ⭐⭐ WAVE 31 Β· T23 (owner item 1) β€” WHAT THE PANE IS ENTITLED TO SAY, AS A FUNCTION.
 *
 * Owner, verbatim: *"Alerts shows notification, but when clicked it says nothing, and it doesn't
 * remove the notification number."* Both halves are ONE mechanism. `AlertsPane` held its own
 * `inbox` seeded to {@link EMPTY_INBOX} and had no pending state, so between opening the panel
 * and `GET /notifications` answering β€” measured at **3,280 ms live** β€” and for ever after a
 * failed fetch:
 *   Β· `items` was `[]`, so the pane printed **"Nothing new."** β€” a claim, not a wait;
 *   Β· `unread` was `0`, so **"Mark all read" was `disabled`**, and the badge the frame had
 *     already loaded could never be cleared.
 * A confident sentence about somebody's inbox, and the one control that would fix it, both
 * switched off by the same uninitialised state.
 *
 * β›” IT IS A FUNCTION BECAUSE THE PANE IS TSX AND TSX IS NOT UNDER TEST HERE. `verify_alerts`
 * compiles and RUNS this module under node; markup it cannot reach is markup no control can
 * mutate. Deciding here means the truth table is asserted and each branch is load-bearing.
 */
export type PaneView = "pending" | "rows" | "empty" | "error";

/**
 * `pending` while the first read is in flight Β· `error` when it failed and we have nothing to
 * show Β· `rows` when there is something Β· `empty` ONLY when a successful read returned nothing.
 *
 * ⚠ ROWS WIN OVER AN ERROR, and that is deliberate rather than lax: a refresh that fails while
 * the pane already holds notifications should not blank them β€” the reader loses real information
 * to a transient. The error still reaches them as the pane's message line.
 */
export function paneView(
  phase: "pending" | "ready" | "error",
  rowCount: number,
  seededUnread = 0
): PaneView {
  if (rowCount > 0) return "rows";
  if (phase === "pending") return "pending";
  if (phase === "error") return "error";
  // ⚠ A SUCCESSFUL READ WITH NO ROWS AND A NON-ZERO COUNT IS NOT "nothing new". The badge says
  // there is something; the page we were given does not contain it. Saying "Nothing new" there
  // is the same false confidence in a different costume.
  return seededUnread > 0 ? "error" : "empty";
}

/**
 * May "Mark all read" be pressed?
 *
 * β›” NOT `unread === 0`, WHICH IS THE SHIPPED BUG. That test asked the PANE's own state β€” zero
 * until its fetch lands, zero for ever if the fetch fails β€” so the control was dead in exactly
 * the situations the owner hit. The question is about the ACCOUNT, so it is asked of the count
 * the frame already holds, and a failed read does not take the verb away: `POST /notifications/
 * read` with `ids: null` clears the account's inbox whether or not we managed to list it.
 */
export function canMarkAll(unread: number, busy = false): boolean {
  return !busy && Math.max(0, Math.floor(unread)) > 0;
}

const str = (v: unknown): string => (typeof v === "string" ? v : "");
const num = (v: unknown): number => (typeof v === "number" && isFinite(v) ? v : 0);

/**
 * ⭐ WAVE 32 Β· C3 β€” one wire `target` β†’ a {@link NotificationTarget}, or `null`.
 *
 * β›” BOTH `module` AND `id` ARE REQUIRED, and dropping either test is the failure this guard is
 * for: a target with a module and no id dispatches an open request naming NOTHING β€” a click that
 * appears to work and silently does not, which is this repo's most-repeated shape. `null` is
 * rendered as an unclickable row, which a reader can SEE.
 */
export function parseTarget(raw: unknown): NotificationTarget | null {
  if (!raw || typeof raw !== "object") return null;
  const t = raw as Record<string, unknown>;
  const module = str(t.module).trim();
  const id = str(t.id).trim();
  if (!module || !id) return null;
  const tab = str(t.tab).trim();
  return { module, id, ...(tab ? { tab } : {}) };
}

/**
 * `GET /notifications` β†’ the inbox, fail-closed.
 *
 * ⚠ `unread` COMES FROM THE SERVER, and is not recounted from `items`. The two
 * can legitimately differ β€” the list is what this page holds, the count is what
 * the account has β€” and recomputing it here would make the badge a function of
 * whatever the last fetch happened to include.
 */
export function parseInbox(body: unknown): Inbox {
  const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
  const raw = Array.isArray(b.items) ? b.items : [];
  const items: Notification[] = [];
  for (const item of raw) {
    if (!item || typeof item !== "object") continue;
    const n = item as Record<string, unknown>;
    const id = str(n.id);
    // An id-less notification cannot be marked read, so it would sit unread for
    // ever and hold the badge up. Dropped, not rendered.
    if (!id) continue;
    items.push({
      id,
      alertId: str(n.alertId),
      viewId: str(n.viewId),
      topic: str(n.topic),
      rowId: String(n.rowId ?? ""),
      label: str(n.label) || String(n.rowId ?? ""),
      alertLabel: str(n.alertLabel),
      at: str(n.at),
      read: n.read === true,
      // WAVE 23 C6 β€” ADDITIVE and spread-conditional, exactly like `NavPage`'s flags: a payload
      // that predates this wave keeps today's shape rather than gaining four `undefined` keys,
      // and an `automation_review` row missing its `autoId` is left WITHOUT one rather than
      // with an empty string that would render as a real destination.
      ...(str(n.kind) ? { kind: str(n.kind) } : {}),
      ...(str(n.autoId) ? { autoId: str(n.autoId) } : {}),
      ...(str(n.stageId) ? { stageId: str(n.stageId) } : {}),
      ...(num(n.count) > 0 ? { count: num(n.count) } : {}),
      // ⭐ WAVE 32 Β· C3 β€” additive and spread-conditional, exactly like the four above.
      ...(str(n.subject) ? { subject: str(n.subject) } : {}),
      // ⭐ WAVE 33 Β· W33-T28 β€” additive and spread-conditional, like every flag above it.
      ...(str(n.sender) ? { sender: str(n.sender) } : {}),
      ...(parseTarget(n.target) ? { target: parseTarget(n.target)! } : {}),
    });
  }
  // ⭐ W33-T28 / D-208 β€” the server's clock rides through, spread-conditionally like every other
  // additive key here, so a server that predates this wave yields the same object it always did.
  return {
    unread: Math.max(0, num(b.unread)),
    items,
    ...(str(b.now) ? { now: str(b.now) } : {}),
  };
}

/** `GET /alerts` β†’ the alert list, fail-closed. */
export function parseAlerts(body: unknown): Alert[] {
  const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
  const raw = Array.isArray(b.alerts) ? b.alerts : [];
  const out: Alert[] = [];
  for (const item of raw) {
    if (!item || typeof item !== "object") continue;
    const a = item as Record<string, unknown>;
    const id = str(a.id);
    if (!id) continue;
    out.push({
      id,
      viewId: str(a.viewId),
      topic: str(a.topic),
      owner: str(a.owner),
      label: str(a.label) || "Untitled alert",
      createdAt: str(a.createdAt),
      matched: num(a.matched),
      seeded: a.seeded === true,
      lastRunAt: str(a.lastRunAt),
      lastError: str(a.lastError),
    });
  }
  return out;
}

/**
 * A topic (the grid's scope key) β†’ the hash route that renders it.
 *
 * The two built-ins are the only pair that differ, and they differ because the
 * REGISTRY names the surface while the GRID names the scope; a user table is its
 * own key in both. `null` for anything else: a notification for a topic this
 * client cannot route to must do nothing, not navigate somewhere plausible.
 */
export function routeForTopic(topic: string): string | null {
  const t = str(topic).trim();
  if (t === "customer") return "customer_data";
  if (t === "product") return "product_data";
  if (/^ut_[A-Za-z0-9_]+$/.test(t)) return t;
  return null;
}






/**
 * The stamp, made readable WITHOUT touching a clock.
 *
 * β›” NO `new Date()`, NO `toLocaleString()`, and that is the whole design. The
 * server sends UTC WITH ITS OFFSET (D-18) precisely so every reader sees the
 * same instant; parsing it into a browser Date and formatting it back would
 * re-introduce the drift the offset exists to remove β€” a tenant a day ahead
 * being told an event happened tomorrow ([[date-window-vocabulary]]). This is
 * STRING SURGERY: keep the date and the minutes, drop the seconds and the `T`.
 * Anything that does not look like an ISO stamp passes through untouched, so a
 * format this function has never seen is shown as sent rather than mangled.
 */
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
                "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

export function stampText(at: string, now?: string): string {
  const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(str(at));
  if (!m) return str(at);
  const [, day, hhmm] = m;
  // ⭐⭐ W33-T28 / D-208 β€” MAIL-SHAPED, AND STILL STRING SURGERY.
  //
  // β›” `now` COMES FROM THE SERVER (`inbox.now`), NEVER FROM `new Date()`. That is D-208's exit
  // condition word for word β€” "without reading the browser clock" β€” and the reason is the same
  // one this function has always carried: `at` is sent as UTC WITH its offset (D-18) so every
  // reader sees the same instant, and deciding "is this today?" against a browser clock would
  // re-introduce exactly the drift the offset removes. Both operands are the server's.
  //
  // ⚠ AND WITHOUT `now` IT DEGRADES TO THE OLD FORMAT RATHER THAN GUESSING. A caller that has no
  // server clock gets `2026-08-13 09:41` β€” the pre-wave string, unambiguous and never wrong β€”
  // instead of a relative stamp computed from something we do not trust.
  const today = /^(\d{4}-\d{2}-\d{2})/.exec(str(now));
  if (!today) return `${day} ${hhmm}`;
  if (today[1] === day) return hhmm;
  const [y, mo, d] = day.split("-");
  const month = MONTHS[Number(mo) - 1] || mo;
  // The year only when it differs β€” a mail client does not print "2026" on a message from March.
  return today[1].slice(0, 4) === y
    ? `${month} ${Number(d)}`
    : `${month} ${Number(d)}, ${y}`;
}

/**
 * The badge's text. Never the raw number past 99: a nav row is 236px wide and a
 * four-digit badge pushes the label out of it.
 */
export function badgeText(unread: number): string {
  const n = Math.max(0, Math.floor(unread));
  if (n <= 0) return "";
  return n > 99 ? "99+" : String(n);
}

/**
 * Apply a read/unread change LOCALLY, mirroring what the server just stored, and
 * return the new inbox with the count corrected.
 *
 * `ids === null` is "all of them" (the API's own convention for mark-all). The
 * count is derived from the ITEMS here β€” deliberately, and it is the one place
 * that is right to do so: the server's answer is in flight, and the alternative
 * is a badge that keeps its old number until the refetch lands.
 */
export function applyRead(inbox: Inbox, ids: string[] | null, read: boolean): Inbox {
  const wanted = ids === null ? null : new Set(ids);
  const items = inbox.items.map((n) =>
    wanted === null || wanted.has(n.id) ? { ...n, read } : n
  );
  const seenUnread = items.filter((n) => !n.read).length;
  // A page can hold fewer notifications than the account has, so a partial read
  // must SUBTRACT from the server's count rather than replace it with this
  // page's tally β€” except when marking everything, where zero is the answer.
  if (wanted === null) return { unread: read ? 0 : items.length, items };
  const changed = inbox.items.filter(
    (n) => wanted.has(n.id) && n.read !== read
  ).length;
  const delta = read ? -changed : changed;
  return { unread: Math.max(seenUnread, inbox.unread + delta), items };
}

/**
 * The shell↔rail channel for "make an alert out of this view" β€” the view rail
 * raises it, the frame (which knows the current route, and therefore the topic)
 * answers. Same reason as C-SHARE's event: the rail is host-neutral and cannot
 * import the shell, and it does not know its own scope key.
 */
export const ALERT_CREATE_EVENT = "aios:alert-create";

export interface AlertCreateRequest {
  viewId: string;
  label: string;
}

export function parseAlertCreate(detail: unknown): AlertCreateRequest | null {
  if (!detail || typeof detail !== "object") return null;
  const d = detail as Record<string, unknown>;
  const viewId = str(d.viewId).trim();
  if (!viewId) return null;
  return { viewId, label: str(d.label).trim() || viewId };
}