g1t/apps/sudo/app/lib/signals.ts
| 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 | |
| 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 | |
| 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. */ |
| 102 | export 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. */ |
| 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 | } |