| 1 | /** |
| 2 | * A work item's page, worked out: its keys, the list it was opened from |
| 3 | * (for J and K), its Activity's filter, the stage it is in and why, and |
| 4 | * what the pickers read from what is typed. Pure, so it is tested under |
| 5 | * Node; the page draws it (routes/workspace/work/item.tsx). |
| 6 | */ |
| 7 | import type { Appetite, Claim, Horizon, WorkItem, WorkPull, WorkSession } from "@g1t/contracts"; |
| 8 | |
| 9 | import { STAGE_INFO, type Stage, stageOf } from "./board.ts"; |
| 10 | |
| 11 | export { ITEM_KEYS, type ItemCommand, itemCommandOf, keyFor } from "./item-keys.ts"; |
| 12 | export { TRAIL_LIMIT, type WorkTrail, neighbours, trailOf, trailState } from "./work-trail.ts"; |
| 13 | |
| 14 | // --- Activity --------------------------------------------------------------- |
| 15 | |
| 16 | export type ActivityFilter = "all" | "comments" | "events"; |
| 17 | |
| 18 | export const ACTIVITY_FILTERS: { value: ActivityFilter; label: string }[] = [ |
| 19 | { value: "all", label: "All" }, |
| 20 | { value: "comments", label: "Comments" }, |
| 21 | { value: "events", label: "Events" }, |
| 22 | ]; |
| 23 | |
| 24 | /** The filter an address asks for (`?activity=comments`); All otherwise. */ |
| 25 | export function activityFilterOf(value: string | null | undefined): ActivityFilter { |
| 26 | return ACTIVITY_FILTERS.find((one) => one.value === value)?.value ?? "all"; |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * The Activity stream, filtered: what people wrote is a comment; what |
| 31 | * happened (a note the service wrote, `kind: "event"`, and every extra: |
| 32 | * sessions, claims, handoffs) is an event. The conversation draws the |
| 33 | * rest, oldest first (lib/comments.ts `interleave`). |
| 34 | */ |
| 35 | export function activityParts<C extends { kind: string }, X>( |
| 36 | comments: readonly C[], |
| 37 | extras: readonly X[], |
| 38 | filter: ActivityFilter, |
| 39 | ): { comments: C[]; extras: X[] } { |
| 40 | if (filter === "comments") return { comments: comments.filter((one) => one.kind !== "event"), extras: [] }; |
| 41 | if (filter === "events") return { comments: comments.filter((one) => one.kind === "event"), extras: [...extras] }; |
| 42 | return { comments: [...comments], extras: [...extras] }; |
| 43 | } |
| 44 | |
| 45 | /** How many of each the stream holds, for the filter's buttons. */ |
| 46 | export function activityCounts(comments: readonly { kind: string }[], extras: number): Record<ActivityFilter, number> { |
| 47 | const events = comments.filter((one) => one.kind === "event").length + extras; |
| 48 | return { all: comments.length + extras, comments: comments.length - (events - extras), events }; |
| 49 | } |
| 50 | |
| 51 | // --- Stage, with why ---------------------------------------------------------- |
| 52 | |
| 53 | /** The stage a work item is in, as Work's board reads it (lib/board.ts), with why in a few words. */ |
| 54 | export type ItemStage = { stage: Stage | "closed"; label: string; why: string }; |
| 55 | |
| 56 | /** |
| 57 | * Its stage, read from what is so, and the facts that put it there: "In |
| 58 | * review: #318 open in acme/web". A closed one is Done for 14 days, then |
| 59 | * Closed; one closed as not planned is Closed. |
| 60 | */ |
| 61 | export function itemStage( |
| 62 | item: WorkItem, |
| 63 | facts: { |
| 64 | repos?: readonly { path: string; pulls: readonly Pick<WorkPull, "number" | "status">[] }[]; |
| 65 | claims?: readonly Claim[]; |
| 66 | sessions?: readonly WorkSession[]; |
| 67 | }, |
| 68 | now: number, |
| 69 | ): ItemStage { |
| 70 | const repos = facts.repos ?? []; |
| 71 | const pulls = repos.flatMap((repo) => repo.pulls.map((pull) => ({ ...pull, repo: repo.path }))); |
| 72 | const claims = (facts.claims ?? []).filter((claim) => claim.state === "active"); |
| 73 | const live = (facts.sessions ?? []).filter((session) => session.state !== "ended" && !session.parentId); |
| 74 | const stage = stageOf( |
| 75 | item, |
| 76 | { |
| 77 | pulls: pulls.map((pull) => ({ key: `${pull.repo}#${pull.number}`.toLowerCase(), status: pull.status, part: false })), |
| 78 | claimed: claims.length > 0, |
| 79 | live: live.length > 0, |
| 80 | }, |
| 81 | now, |
| 82 | ); |
| 83 | if (stage == null) { |
| 84 | return { stage: "closed", label: "Closed", why: item.reason === "not_planned" ? "Closed as not planned" : "Closed" }; |
| 85 | } |
| 86 | const label = STAGE_INFO[stage].label; |
| 87 | const pullAt = (pull: (typeof pulls)[number]) => `#${pull.number} ${pull.status === "draft" ? "draft" : pull.status} in ${pull.repo}`; |
| 88 | switch (stage) { |
| 89 | case "done": |
| 90 | return { |
| 91 | stage, |
| 92 | label, |
| 93 | why: item.resolutions.length > 0 ? `Landed in ${[...new Set(item.resolutions.map((one) => one.repo))].join(", ")}` : "Closed as completed", |
| 94 | }; |
| 95 | case "landing": { |
| 96 | const missing = item.repos.filter((repo) => repo.role === "target" && !repo.landed).map((repo) => repo.path); |
| 97 | return { stage, label, why: missing.length > 0 ? `Still to land in ${missing.join(", ")}` : "Landing" }; |
| 98 | } |
| 99 | case "review": { |
| 100 | const open = pulls.filter((pull) => pull.status === "open"); |
| 101 | return { stage, label, why: open.map(pullAt).join(", ") }; |
| 102 | } |
| 103 | case "progress": { |
| 104 | const reasons: string[] = []; |
| 105 | if (claims.length > 0) reasons.push(`claimed by ${[...new Set(claims.map((claim) => claim.actor.username))].join(", ")}`); |
| 106 | if (live.length > 0) reasons.push(live.length === 1 ? "a session on it" : `${live.length} sessions on it`); |
| 107 | const drafts = pulls.filter((pull) => pull.status === "draft"); |
| 108 | if (drafts.length > 0) reasons.push(drafts.map(pullAt).join(", ")); |
| 109 | if (reasons.length === 0 && item.assignees.length > 0) reasons.push(`assigned to ${item.assignees.join(", ")}`); |
| 110 | if (reasons.length === 0 && pulls.some((pull) => pull.status === "merged")) reasons.push("a pull request merged without closing it"); |
| 111 | const text = reasons.join(", "); |
| 112 | return { stage, label, why: text.charAt(0).toUpperCase() + text.slice(1) }; |
| 113 | } |
| 114 | default: |
| 115 | return { stage, label, why: "Nobody is on it yet" }; |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // --- What the pickers read ---------------------------------------------------- |
| 120 | |
| 121 | /** A work item as a picker lists it (routes/workspace/work/items-json.ts). */ |
| 122 | export type ItemChoice = { number: number; title: string; state: "open" | "closed" }; |
| 123 | |
| 124 | /** |
| 125 | * A budget typed in the Plan picker, `5`, `$12.50` or `none`: dollars, or |
| 126 | * null to clear it; undefined when what is typed is no budget. |
| 127 | */ |
| 128 | export function budgetOf(text: string): number | null | undefined { |
| 129 | const word = text.trim().toLowerCase().replace(/^\$/, ""); |
| 130 | if (word === "none" || word === "no budget") return null; |
| 131 | if (!/^\d+(\.\d{1,2})?$/.test(word)) return undefined; |
| 132 | const dollars = Number(word); |
| 133 | return dollars > 0 && dollars <= 100_000 ? dollars : undefined; |
| 134 | } |
| 135 | |
| 136 | /** A work item's number typed in a picker (`#212`, `212`), or null. */ |
| 137 | export function typedNumber(text: string): number | null { |
| 138 | const word = text.trim().replace(/^#/, ""); |
| 139 | return /^\d{1,9}$/.test(word) && Number(word) > 0 ? Number(word) : null; |
| 140 | } |
| 141 | |
| 142 | /** A username typed in the assignee picker (`@ana`, `ana`), tidied; null for none or anything that cannot be one. */ |
| 143 | export function typedUsername(text: string): string | null { |
| 144 | const word = text.trim().replace(/^@/, "").toLowerCase(); |
| 145 | return /^[a-z0-9](?:[a-z0-9-]{0,38})$/.test(word) ? word : null; |
| 146 | } |
| 147 | |
| 148 | /** A set with `value` taken out when it is in it, or added at the end when it is not. */ |
| 149 | export function toggled<T>(values: readonly T[], value: T): T[] { |
| 150 | return values.includes(value) ? values.filter((one) => one !== value) : [...values, value]; |
| 151 | } |
| 152 | |
| 153 | /** Whether two lists hold the same values, in any order. */ |
| 154 | export function sameSet<T>(a: readonly T[], b: readonly T[]): boolean { |
| 155 | return a.length === b.length && a.every((one) => b.includes(one)); |
| 156 | } |
| 157 | |
| 158 | /** The plan as one line for the panel: `Now · M · $5 · Urgent`. */ |
| 159 | export function planSummary( |
| 160 | item: { horizon: Horizon; appetite: Appetite | null; budgetUsd: number | null; urgent: boolean }, |
| 161 | horizonLabel: (h: Horizon) => string, |
| 162 | ): string { |
| 163 | const parts = [horizonLabel(item.horizon)]; |
| 164 | if (item.appetite) parts.push(item.appetite.charAt(0).toUpperCase()); |
| 165 | if (item.budgetUsd != null) parts.push(`$${Number.isInteger(item.budgetUsd) ? item.budgetUsd : item.budgetUsd.toFixed(2)}`); |
| 166 | if (item.urgent) parts.push("Urgent"); |
| 167 | return parts.join(" · "); |
| 168 | } |