Skip to content

g1t/apps/web/app/lib/inbox.ts

146 lines5,956 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all1/**
2 * The inbox's tabs, times and actions, shared by the panel in the top bar
3 * (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx). The
4 * events service keeps the items and decides who is told of what.
5 */
6import type { InboxCounts, InboxItem, InboxMarkArgs, InboxSeverity, InboxView } from "@g1t/contracts";
7
8export type InboxTab = "all" | "needs" | "error" | "success" | "info";
9
10/** The tabs, in order, and the severity each shows. */
11export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [
12 { tab: "all", label: "All", severity: null },
13 // Warnings are what an agent is waiting on a person for.
14 { tab: "needs", label: "Needs you", severity: "warning" },
15 { tab: "error", label: "Errors", severity: "error" },
16 { tab: "success", label: "Success", severity: "success" },
17 { tab: "info", label: "Info", severity: "info" },
18];
19
20/** The words on an item's badge. */
21export const SEVERITY_LABEL: Record<InboxSeverity, string> = {
22 error: "Error",
23 warning: "Needs you",
24 success: "Success",
25 info: "Info",
26};
27
28export function inboxTab(value: string | null | undefined): InboxTab {
29 return INBOX_TABS.find((entry) => entry.tab === value)?.tab ?? "all";
30}
31
32export function severityOf(tab: InboxTab): InboxSeverity | null {
33 return INBOX_TABS.find((entry) => entry.tab === tab)?.severity ?? null;
34}
35
36export function inboxView(value: string | null | undefined): InboxView {
37 return value === "saved" || value === "done" ? value : "inbox";
38}
39
40/** What is unread under a tab. */
41export function tabCount(counts: InboxCounts | null | undefined, tab: InboxTab): number {
42 if (!counts) return 0;
43 const severity = severityOf(tab);
44 return severity ? counts[severity] : counts.unread;
45}
46
47/** The bell's number: up to 99, then "99+". Empty when nothing is unread. */
48export function bellCount(unread: number | null | undefined): string {
49 if (!unread || unread < 1) return "";
50 return unread > 99 ? "99+" : String(unread);
51}
52
53/** What an empty tab says. */
54export function emptyFor(tab: InboxTab, view: InboxView = "inbox"): { title: string; detail: string } {
55 if (view === "saved") return { title: "Nothing saved", detail: "Save an item to keep it here after it is done." };
56 if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." };
57 switch (tab) {
58 case "needs":
59 return { title: "Nothing needs you", detail: "When an agent is waiting on you, it shows up here first." };
60 case "error":
61 return { title: "No failures", detail: "Failed checks and workflows on your work show up here." };
62 case "success":
63 return { title: "Nothing new landed", detail: "Merges, approvals and finished agent work show up here." };
64 case "info":
65 return { title: "No mentions or comments", detail: "Mentions of you and comments on your work show up here." };
66 default:
67 return { title: "You're all caught up", detail: "What needs you, or what you follow, shows up here as it happens." };
68 }
69}
70
71const MINUTE = 60_000;
72const HOUR = 60 * MINUTE;
73const DAY = 24 * HOUR;
74
75/** When, in a few characters: "just now", "15m ago", "3h ago", "Yesterday", "4d ago", "Sep 30". */
76export function whenShort(at: string, now: number): string {
77 const time = Date.parse(at);
78 const elapsed = Math.max(0, now - time);
79 if (elapsed < MINUTE) return "just now";
80 if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
81 if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
82 if (elapsed < 2 * DAY) return "Yesterday";
83 if (elapsed < 7 * DAY) return `${Math.floor(elapsed / DAY)}d ago`;
84 return new Date(time).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
85}
86
87/** How long an item can be snoozed for. */
88export const SNOOZES = [
89 { key: "3h", label: "3 hours", ms: 3 * HOUR },
90 { key: "tomorrow", label: "Tomorrow", ms: DAY },
91 { key: "week", label: "Next week", ms: 7 * DAY },
92] as const;
93
94export function snoozeUntil(key: string | null | undefined, now: number): string | null {
95 const snooze = SNOOZES.find((entry) => entry.key === key);
96 return snooze ? new Date(now + snooze.ms).toISOString() : null;
97}
98
99/** What the inbox forms post, by `intent`. */
100const MARKS: Record<string, InboxMarkArgs["mark"]> = {
101 read: "read",
102 unread: "unread",
103 done: "done",
104 undone: "undone",
105 save: "save",
106 unsave: "unsave",
107 snooze: "snooze",
108};
109
110/**
111 * A posted inbox form as the mark it asks for: one item (`id`), or every
112 * item (`intent` `read_all`, optionally for one tab). Null when it asks for
113 * nothing the inbox does.
114 */
115export function markFromForm(form: FormData, now: number): InboxMarkArgs | null {
116 const intent = String(form.get("intent") ?? "");
117 if (intent === "read_all") {
118 return { mark: "read", all: true, severity: severityOf(inboxTab(String(form.get("tab") ?? ""))) };
119 }
120 const mark = MARKS[intent];
121 const id = String(form.get("id") ?? "").trim();
122 if (!mark || !id) return null;
123 if (mark === "snooze") {
124 const until = snoozeUntil(String(form.get("for") ?? ""), now);
125 return until ? { mark, ids: [id], until } : null;
126 }
127 return { mark, ids: [id] };
128}
129
130/**
131 * Mission control's Needs you card, from unread items: what an agent is
132 * waiting on first, then failures, newest first within each; `max` of them,
133 * and how many there are in all.
134 */
135export function needsYou(items: InboxItem[], max: number): { items: InboxItem[]; total: number } {
136 const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1);
137 const needs = items
138 .filter((item) => isUnread(item) && (item.severity === "warning" || item.severity === "error"))
139 .sort((a, b) => rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt));
140 return { items: needs.slice(0, max), total: needs.length };
141}
142
143/** Whether an item is still unread. */
144export function isUnread(item: Pick<InboxItem, "readAt">): boolean {
145 return item.readAt == null;
146}