| 1 | /** |
| 2 | * Cards people can act on in chat (docs/WORKSPACE.md, "Cards"): how a |
| 3 | * card's state reads, which of its actions are links and which run, and |
| 4 | * the money and text people type into them. Pure, so it is tested on its |
| 5 | * own; components/chat/card.tsx draws them. |
| 6 | */ |
| 7 | import type { CardAction, MessageCard } from "@g1t/contracts"; |
| 8 | |
| 9 | import type { SessionChip } from "./session-card"; |
| 10 | |
| 11 | /** A card's state chip, as a session's is, outlined only when it is waiting quietly. */ |
| 12 | export type CardChip = { tone: SessionChip["tone"] | "merged"; live: boolean; outline?: boolean }; |
| 13 | |
| 14 | const DRAFT_CHIPS: Record<string, CardChip> = { |
| 15 | // Waiting on someone to file it: lavender, outlined only. |
| 16 | draft: { tone: "accent", live: false, outline: true }, |
| 17 | filed: { tone: "success", live: false }, |
| 18 | discarded: { tone: "neutral", live: false }, |
| 19 | }; |
| 20 | |
| 21 | /** |
| 22 | * How a draft issue's state reads; null for other kinds (a session's chip |
| 23 | * is lib/session-card.ts's, others are guessed from their words). |
| 24 | */ |
| 25 | export function cardChip(card: Pick<MessageCard, "kind" | "state">): CardChip | null { |
| 26 | if (!card.state) return null; |
| 27 | if (card.kind === "draft_issue") return DRAFT_CHIPS[card.state.trim().toLowerCase()] ?? { tone: "neutral", live: false }; |
| 28 | return null; |
| 29 | } |
| 30 | |
| 31 | /** What pressing an action does: opens a place, asks first, asks for a value first, or just runs. */ |
| 32 | export type ActionMode = "link" | "confirm" | "input" | "run"; |
| 33 | |
| 34 | export function actionMode(action: CardAction): ActionMode { |
| 35 | if (action.href) return "link"; |
| 36 | if (action.input) return "input"; |
| 37 | if (action.confirm) return "confirm"; |
| 38 | return "run"; |
| 39 | } |
| 40 | |
| 41 | /** |
| 42 | * The actions a card shows: links always; the rest only when the card has |
| 43 | * an owner to answer them (chat drops them otherwise, this is the same rule |
| 44 | * for a card that never went through it). |
| 45 | */ |
| 46 | export function shownActions(card: Pick<MessageCard, "actions" | "owner">): CardAction[] { |
| 47 | return (card.actions ?? []).filter((a) => !!a.href || !!card.owner); |
| 48 | } |
| 49 | |
| 50 | /** Dollars as people type them ("5", "$12.5", "1,200") as "12.50", or null when it is not an amount. */ |
| 51 | export function moneyValue(input: string | null | undefined): string | null { |
| 52 | const text = String(input ?? "") |
| 53 | .trim() |
| 54 | .replace(/^\$\s*/, "") |
| 55 | .replace(/,/g, ""); |
| 56 | if (!/^\d+(\.\d{0,2})?$|^\.\d{1,2}$/.test(text)) return null; |
| 57 | const amount = Number(text); |
| 58 | if (!Number.isFinite(amount) || amount <= 0) return null; |
| 59 | return amount.toFixed(2); |
| 60 | } |
| 61 | |
| 62 | /** What a money field starts with: the suggestion as an amount, or empty. */ |
| 63 | export function moneyInitial(initial: string | null | undefined): string { |
| 64 | return moneyValue(initial) ?? ""; |
| 65 | } |
| 66 | |
| 67 | /** What is typed into a text action, trimmed, or null when there is nothing. */ |
| 68 | export function textValue(input: string | null | undefined): string | null { |
| 69 | const text = String(input ?? "").trim(); |
| 70 | return text ? text : null; |
| 71 | } |
| 72 | |
| 73 | /** The value an input action sends, or null when it is not ready to send. */ |
| 74 | export function inputValue(action: CardAction, typed: string): string | null { |
| 75 | if (!action.input) return null; |
| 76 | return action.input.kind === "money" ? moneyValue(typed) : textValue(typed); |
| 77 | } |
| 78 | |
| 79 | /** Whether a body is long enough that its preview starts folded (over about eight lines). */ |
| 80 | export function foldsBody(body: string, lines = 8): boolean { |
| 81 | const rows = body.split("\n").reduce((n, line) => n + Math.max(1, Math.ceil(line.length / 80)), 0); |
| 82 | return rows > lines; |
| 83 | } |