g1t/apps/sudo/app/lib/signals.ts
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.
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 1 | /** |
| 2 | * Reach out: why a workspace is worth a word, how urgent that is, and what | |
| 3 | * staff are doing about it. Billing finds the signals and keeps the sales | |
| 4 | * records; this orders, filters and names them. No Workers imports, so it | |
| 5 | * can be tested under Node. | |
| 6 | */ | |
| 7 | import type { SalesStage, Signal, SignalKind } from "@g1t/contracts"; | |
| 8 | ||
| 9 | export type Tone = "plain" | "lavender" | "mint" | "warn" | "danger" | "info"; | |
| 10 | ||
| 11 | /** Each kind of signal, most urgent first. */ | |
| 12 | export const SIGNAL_KINDS: { kind: SignalKind; label: string; tone: Tone; about: string }[] = [ | |
| 13 | { kind: "at_limit", label: "At limit", tone: "danger", about: "Work is stopped: it reached its limit or its owners' spend limit." }, | |
| 14 | { kind: "declined", label: "Declined", tone: "danger", about: "Its card was declined or a payment disputed." }, | |
| 15 | { kind: "near_ceiling", label: "Near limit", tone: "warn", about: "Past 80% of what is available to it; about to need more." }, | |
| 16 | { kind: "high_spend", label: "High spend", tone: "lavender", about: "Spending enough that custom terms or an enterprise may suit it." }, | |
| 17 | { kind: "growing", label: "Growing", tone: "mint", about: "This month is well ahead of last month." }, | |
| 18 | { kind: "established", label: "Established", tone: "info", about: "Became Established: its limit now follows its spend." }, | |
| 19 | { kind: "first_payment", label: "First payment", tone: "mint", about: "Paid g1t for the first time." }, | |
| 20 | ]; | |
| 21 | ||
| 22 | const RANK = new Map(SIGNAL_KINDS.map((entry, index) => [entry.kind as string, index])); | |
| 23 | ||
| 24 | export function signalMeta(kind: string): { label: string; tone: Tone; about: string } { | |
| 25 | return SIGNAL_KINDS.find((entry) => entry.kind === kind) ?? { label: kind.replace(/_/g, " "), tone: "plain", about: "" }; | |
| 26 | } | |
| 27 | ||
| 28 | export function isSignalKind(value: string | null | undefined): value is SignalKind { | |
| 29 | return value != null && RANK.has(value); | |
| 30 | } | |
| 31 | ||
| 32 | /** | |
| 33 | * Most urgent first: by kind, then the larger figure. Stable, so billing's | |
| 34 | * own order holds between equals. A kind sudo does not know goes last. | |
| 35 | */ | |
| 36 | export function bySignalUrgency(signals: Signal[]): Signal[] { | |
| 37 | return signals | |
| 38 | .map((signal, index) => ({ signal, index })) | |
| 39 | .sort( | |
| 40 | (a, b) => | |
| 41 | (RANK.get(a.signal.kind) ?? RANK.size) - (RANK.get(b.signal.kind) ?? RANK.size) || | |
| 42 | b.signal.valueMicros - a.signal.valueMicros || | |
| 43 | a.index - b.index, | |
| 44 | ) | |
| 45 | .map(({ signal }) => signal); | |
| 46 | } | |
| 47 | ||
| 48 | /** Whose signals to show: everyone's, nobody's yet, or the signed-in staff member's. */ | |
| 49 | export type Who = "all" | "unassigned" | "mine"; | |
| 50 | ||
| 51 | export function parseWho(value: string | null): Who { | |
| 52 | return value === "unassigned" || value === "mine" ? value : "all"; | |
| 53 | } | |
| 54 | ||
| 55 | function sameEmail(a: string | null | undefined, b: string): boolean { | |
| 56 | return a != null && a.trim().toLowerCase() === b.trim().toLowerCase(); | |
| 57 | } | |
| 58 | ||
| 59 | /** The signals that match a kind (or any) and whose they are. */ | |
| 60 | export function filterSignals(signals: Signal[], { kind, who, me }: { kind: SignalKind | null; who: Who; me: string }): Signal[] { | |
| 61 | return signals.filter((signal) => { | |
| 62 | if (kind && signal.kind !== kind) return false; | |
| 63 | if (who === "unassigned") return !signal.owner; | |
| 64 | if (who === "mine") return sameEmail(signal.owner, me); | |
| 65 | return true; | |
| 66 | }); | |
| 67 | } | |
| 68 | ||
| sudo: Invoices, Audit log, and follow-ups due | 69 | /** Today, as billing compares follow-up days: `YYYY-MM-DD`, UTC. */ |
| 70 | export function today(now = new Date()): string { | |
| 71 | return now.toISOString().slice(0, 10); | |
| 72 | } | |
| 73 | ||
| 74 | /** A next step due today or earlier, on a deal that is still open. */ | |
| 75 | export function isFollowUpDue(signal: Pick<Signal, "nextAt" | "stage">, on = today()): boolean { | |
| 76 | if (!signal.nextAt || signal.stage === "won" || signal.stage === "lost") return false; | |
| 77 | return signal.nextAt.slice(0, 10) <= on; | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Follow-ups due, one per workspace (a workspace with several signals has | |
| 82 | * one sales record), the longest overdue first. | |
| 83 | */ | |
| 84 | export function followUpsDue(signals: Signal[], on = today()): Signal[] { | |
| 85 | const seen = new Set<string>(); | |
| 86 | return signals | |
| 87 | .filter((signal) => isFollowUpDue(signal, on)) | |
| 88 | .filter((signal) => (seen.has(signal.workspace) ? false : (seen.add(signal.workspace), true))) | |
| 89 | .map((signal, index) => ({ signal, index })) | |
| 90 | .sort((a, b) => (a.signal.nextAt ?? "").localeCompare(b.signal.nextAt ?? "") || a.index - b.index) | |
| 91 | .map(({ signal }) => signal); | |
| 92 | } | |
| 93 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 94 | /** How many signals of each kind, for the filter's counts. */ |
| 95 | export function countByKind(signals: Signal[]): Record<string, number> { | |
| 96 | const counts: Record<string, number> = {}; | |
| 97 | for (const signal of signals) counts[signal.kind] = (counts[signal.kind] ?? 0) + 1; | |
| 98 | return counts; | |
| 99 | } | |
| 100 | ||
| 101 | /** A link to the queue with these filters; the defaults are left out. */ | |
| sudo: Invoices, Audit log, and follow-ups due | 102 | export function reachOutHref({ kind, who, due }: { kind?: string | null; who?: Who; due?: boolean }): string { |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 103 | const params = new URLSearchParams(); |
| sudo: Invoices, Audit log, and follow-ups due | 104 | if (due) params.set("due", "1"); |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 105 | if (kind) params.set("kind", kind); |
| 106 | if (who && who !== "all") params.set("who", who); | |
| 107 | const query = params.toString(); | |
| 108 | return query ? `/reach-out?${query}` : "/reach-out"; | |
| 109 | } | |
| 110 | ||
| 111 | /** The sales stages, in the order a deal moves through them. */ | |
| 112 | export const STAGES: { stage: SalesStage; label: string; tone: Tone }[] = [ | |
| 113 | { stage: "none", label: "No stage", tone: "plain" }, | |
| 114 | { stage: "lead", label: "Lead", tone: "info" }, | |
| 115 | { stage: "contacted", label: "Contacted", tone: "lavender" }, | |
| 116 | { stage: "negotiating", label: "Negotiating", tone: "warn" }, | |
| 117 | { stage: "won", label: "Won", tone: "mint" }, | |
| 118 | { stage: "lost", label: "Lost", tone: "plain" }, | |
| 119 | { stage: "churn_risk", label: "Churn risk", tone: "danger" }, | |
| 120 | ]; | |
| 121 | ||
| 122 | export function stageMeta(stage: string | null | undefined): { label: string; tone: Tone } { | |
| 123 | return STAGES.find((entry) => entry.stage === (stage ?? "none")) ?? { label: String(stage), tone: "plain" }; | |
| 124 | } | |
| 125 | ||
| 126 | export function isStage(value: string): value is SalesStage { | |
| 127 | return STAGES.some((entry) => entry.stage === value); | |
| 128 | } |