flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

128 lines5,879 bytesCodeBlame
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 */
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." },
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
22const RANK = new Map(SIGNAL_KINDS.map((entry, index) => [entry.kind as string, index]));
23
24export 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
28export 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 */
36export 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. */
49export type Who = "all" | "unassigned" | "mine";
50
51export function parseWho(value: string | null): Who {
52 return value === "unassigned" || value === "mine" ? value : "all";
53}
54
55function 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. */
60export 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
69/** Today, as billing compares follow-up days: `YYYY-MM-DD`, UTC. */
70export 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. */
75export 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 */
84export 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
94/** How many signals of each kind, for the filter's counts. */
95export 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. */
102export function reachOutHref({ kind, who, due }: { kind?: string | null; who?: Who; due?: boolean }): string {
103 const params = new URLSearchParams();
104 if (due) params.set("due", "1");
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. */
112export 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
122export 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
126export function isStage(value: string): value is SalesStage {
127 return STAGES.some((entry) => entry.stage === value);
128}