g1t/apps/sudo/app/lib/signals.ts

134 lines6,089 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.

A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales1/**
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 */
7import type { SalesStage, Signal, SignalKind } from "@g1t/contracts";
8
9export type Tone = "plain" | "lavender" | "mint" | "warn" | "danger" | "info";
10
11/** Each kind of signal, most urgent first. */
12export 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." },
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily16 {
17 kind: "cost_over_revenue",
18 label: "Costs more than it pays",
19 tone: "warn",
20 about: "Cost g1t more on Cloudflare than it paid over 30 days: a pricing gap, or abuse. See Costs & margin.",
21 },
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales22 { kind: "high_spend", label: "High spend", tone: "lavender", about: "Spending enough that custom terms or an enterprise may suit it." },
23 { kind: "growing", label: "Growing", tone: "mint", about: "This month is well ahead of last month." },
24 { kind: "established", label: "Established", tone: "info", about: "Became Established: its limit now follows its spend." },
25 { kind: "first_payment", label: "First payment", tone: "mint", about: "Paid g1t for the first time." },
26];
27
28const RANK = new Map(SIGNAL_KINDS.map((entry, index) => [entry.kind as string, index]));
29
30export function signalMeta(kind: string): { label: string; tone: Tone; about: string } {
31 return SIGNAL_KINDS.find((entry) => entry.kind === kind) ?? { label: kind.replace(/_/g, " "), tone: "plain", about: "" };
32}
33
34export function isSignalKind(value: string | null | undefined): value is SignalKind {
35 return value != null && RANK.has(value);
36}
37
38/**
39 * Most urgent first: by kind, then the larger figure. Stable, so billing's
40 * own order holds between equals. A kind sudo does not know goes last.
41 */
42export function bySignalUrgency(signals: Signal[]): Signal[] {
43 return signals
44 .map((signal, index) => ({ signal, index }))
45 .sort(
46 (a, b) =>
47 (RANK.get(a.signal.kind) ?? RANK.size) - (RANK.get(b.signal.kind) ?? RANK.size) ||
48 b.signal.valueMicros - a.signal.valueMicros ||
49 a.index - b.index,
50 )
51 .map(({ signal }) => signal);
52}
53
54/** Whose signals to show: everyone's, nobody's yet, or the signed-in staff member's. */
55export type Who = "all" | "unassigned" | "mine";
56
57export function parseWho(value: string | null): Who {
58 return value === "unassigned" || value === "mine" ? value : "all";
59}
60
61function sameEmail(a: string | null | undefined, b: string): boolean {
62 return a != null && a.trim().toLowerCase() === b.trim().toLowerCase();
63}
64
65/** The signals that match a kind (or any) and whose they are. */
66export function filterSignals(signals: Signal[], { kind, who, me }: { kind: SignalKind | null; who: Who; me: string }): Signal[] {
67 return signals.filter((signal) => {
68 if (kind && signal.kind !== kind) return false;
69 if (who === "unassigned") return !signal.owner;
70 if (who === "mine") return sameEmail(signal.owner, me);
71 return true;
72 });
73}
74
sudo: Invoices, Audit log, and follow-ups due75/** Today, as billing compares follow-up days: `YYYY-MM-DD`, UTC. */
76export function today(now = new Date()): string {
77 return now.toISOString().slice(0, 10);
78}
79
80/** A next step due today or earlier, on a deal that is still open. */
81export function isFollowUpDue(signal: Pick<Signal, "nextAt" | "stage">, on = today()): boolean {
82 if (!signal.nextAt || signal.stage === "won" || signal.stage === "lost") return false;
83 return signal.nextAt.slice(0, 10) <= on;
84}
85
86/**
87 * Follow-ups due, one per workspace (a workspace with several signals has
88 * one sales record), the longest overdue first.
89 */
90export function followUpsDue(signals: Signal[], on = today()): Signal[] {
91 const seen = new Set<string>();
92 return signals
93 .filter((signal) => isFollowUpDue(signal, on))
94 .filter((signal) => (seen.has(signal.workspace) ? false : (seen.add(signal.workspace), true)))
95 .map((signal, index) => ({ signal, index }))
96 .sort((a, b) => (a.signal.nextAt ?? "").localeCompare(b.signal.nextAt ?? "") || a.index - b.index)
97 .map(({ signal }) => signal);
98}
99
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales100/** How many signals of each kind, for the filter's counts. */
101export function countByKind(signals: Signal[]): Record<string, number> {
102 const counts: Record<string, number> = {};
103 for (const signal of signals) counts[signal.kind] = (counts[signal.kind] ?? 0) + 1;
104 return counts;
105}
106
107/** A link to the queue with these filters; the defaults are left out. */
sudo: Invoices, Audit log, and follow-ups due108export 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 sales109 const params = new URLSearchParams();
sudo: Invoices, Audit log, and follow-ups due110 if (due) params.set("due", "1");
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales111 if (kind) params.set("kind", kind);
112 if (who && who !== "all") params.set("who", who);
113 const query = params.toString();
114 return query ? `/reach-out?${query}` : "/reach-out";
115}
116
117/** The sales stages, in the order a deal moves through them. */
118export const STAGES: { stage: SalesStage; label: string; tone: Tone }[] = [
119 { stage: "none", label: "No stage", tone: "plain" },
120 { stage: "lead", label: "Lead", tone: "info" },
121 { stage: "contacted", label: "Contacted", tone: "lavender" },
122 { stage: "negotiating", label: "Negotiating", tone: "warn" },
123 { stage: "won", label: "Won", tone: "mint" },
124 { stage: "lost", label: "Lost", tone: "plain" },
125 { stage: "churn_risk", label: "Churn risk", tone: "danger" },
126];
127
128export function stageMeta(stage: string | null | undefined): { label: string; tone: Tone } {
129 return STAGES.find((entry) => entry.stage === (stage ?? "none")) ?? { label: String(stage), tone: "plain" };
130}
131
132export function isStage(value: string): value is SalesStage {
133 return STAGES.some((entry) => entry.stage === value);
134}