| 1 | /** |
| 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 | */ |
| 6 | import type { InboxCounts, InboxItem, InboxMarkArgs, InboxSeverity, InboxView } from "@g1t/contracts"; |
| 7 | |
| 8 | export type InboxTab = "all" | "needs" | "error" | "success" | "info"; |
| 9 | |
| 10 | /** The tabs, in order, and the severity each shows. */ |
| 11 | export 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. */ |
| 21 | export const SEVERITY_LABEL: Record<InboxSeverity, string> = { |
| 22 | error: "Error", |
| 23 | warning: "Needs you", |
| 24 | success: "Success", |
| 25 | info: "Info", |
| 26 | }; |
| 27 | |
| 28 | export function inboxTab(value: string | null | undefined): InboxTab { |
| 29 | return INBOX_TABS.find((entry) => entry.tab === value)?.tab ?? "all"; |
| 30 | } |
| 31 | |
| 32 | export function severityOf(tab: InboxTab): InboxSeverity | null { |
| 33 | return INBOX_TABS.find((entry) => entry.tab === tab)?.severity ?? null; |
| 34 | } |
| 35 | |
| 36 | export function inboxView(value: string | null | undefined): InboxView { |
| 37 | return value === "saved" || value === "done" ? value : "inbox"; |
| 38 | } |
| 39 | |
| 40 | /** What is unread under a tab. */ |
| 41 | export 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. */ |
| 48 | export 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. */ |
| 54 | export 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 | |
| 71 | const MINUTE = 60_000; |
| 72 | const HOUR = 60 * MINUTE; |
| 73 | const DAY = 24 * HOUR; |
| 74 | |
| 75 | /** When, in a few characters: "just now", "15m ago", "3h ago", "Yesterday", "4d ago", "Sep 30". */ |
| 76 | export 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. */ |
| 88 | export 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 | |
| 94 | export 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`. */ |
| 100 | const 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 | */ |
| 115 | export 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 | */ |
| 135 | export 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. */ |
| 144 | export function isUnread(item: Pick<InboxItem, "readAt">): boolean { |
| 145 | return item.readAt == null; |
| 146 | } |