| 1 | /** |
| 2 | * Work's Plan: what the workspace is on now, what comes next and what can |
| 3 | * wait, and the moves that change it. Where its pages are, how a drop or |
| 4 | * a menu's move becomes a position (`before`, `after` or `top`), what a |
| 5 | * move looks like before the work service answers, and how full Now is. |
| 6 | * Pure, so it is tested under Node. |
| 7 | */ |
| 8 | import type { Appetite, Claim, Horizon, Plan, PlanGroup, PlanWorkItemInput, WorkItem, WorkspaceSession } from "@g1t/contracts"; |
| 9 | |
| 10 | import { workPath } from "./work-items.ts"; |
| 11 | |
| 12 | // The contracts' `HORIZONS` and `APPETITES`, here so the tests run under |
| 13 | // Node, which imports only types from @g1t/contracts. |
| 14 | |
| 15 | /** Every horizon, in the order work moves through them. */ |
| 16 | export const HORIZONS: readonly Horizon[] = ["triage", "now", "next", "later", "set_aside"]; |
| 17 | /** Every appetite, smallest first. */ |
| 18 | export const APPETITES: readonly Appetite[] = ["small", "medium", "large"]; |
| 19 | |
| 20 | /** The Plan, Work's first page. */ |
| 21 | export function planPath(workspace: string, query = ""): string { |
| 22 | return `${workPath(workspace)}${query ? `?${query}` : ""}`; |
| 23 | } |
| 24 | |
| 25 | /** Triage: new work items nobody has placed yet. */ |
| 26 | export function triagePath(workspace: string): string { |
| 27 | return workPath(workspace, "triage"); |
| 28 | } |
| 29 | |
| 30 | /** All items: the Work list, with its filters (`state=closed&label=bug`). */ |
| 31 | export function allItemsPath(workspace: string, query = ""): string { |
| 32 | return `${workPath(workspace, "all")}${query ? `?${query}` : ""}`; |
| 33 | } |
| 34 | |
| 35 | /** The All items list of what is set aside. */ |
| 36 | export function setAsidePath(workspace: string): string { |
| 37 | return allItemsPath(workspace, new URLSearchParams({ q: "horizon:set_aside" }).toString()); |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * The query parameters that belong to the Work list (lib/work-items.ts |
| 42 | * `workFilters`, and the paging and sort it keeps): `-/work` with any of |
| 43 | * them is an old link to the list, which is at `-/work/all` now. |
| 44 | */ |
| 45 | export const LIST_PARAMS = ["q", "label", "milestone", "assignee", "state", "repo", "role", "author", "horizon", "before", "sort", "page"] as const; |
| 46 | |
| 47 | /** Where `-/work` with this query leads instead of the Plan: the list with the same query, or null to show the Plan. */ |
| 48 | export function listRedirect(workspace: string, query: URLSearchParams): string | null { |
| 49 | const kept = new URLSearchParams(query); |
| 50 | kept.delete("_routes"); |
| 51 | kept.delete("index"); |
| 52 | if (!LIST_PARAMS.some((key) => kept.has(key))) return null; |
| 53 | return allItemsPath(workspace, kept.toString()); |
| 54 | } |
| 55 | |
| 56 | /** How full Now is: its share of the cap (at most 1, for the bar) and whether it is at or past it. */ |
| 57 | export type CapState = { count: number; cap: number; share: number; full: boolean; over: boolean }; |
| 58 | |
| 59 | export function capState(count: number, cap: number): CapState { |
| 60 | const limit = Math.max(1, cap); |
| 61 | return { count, cap: limit, share: Math.min(1, count / limit), full: count >= limit, over: count > limit }; |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * Where a move puts a work item among another horizon's (or its own) |
| 66 | * items: before or after another, first, or (none set) where the horizon |
| 67 | * puts it. At most one is set. |
| 68 | */ |
| 69 | export type Position = { before?: number; after?: number; top?: true }; |
| 70 | |
| 71 | /** |
| 72 | * The position for dropping `dragged` into `list` (the numbers as shown, |
| 73 | * top first, which may include `dragged` itself) at `index`, the gap it |
| 74 | * lands in (0 is above the first, `list.length` below the last): before |
| 75 | * the row it lands above, else after the last, else none (an empty list, |
| 76 | * where the horizon alone says where). Null when it lands where it is. |
| 77 | */ |
| 78 | export function dropPosition(list: readonly number[], index: number, dragged: number): Position | null { |
| 79 | const from = list.indexOf(dragged); |
| 80 | if (from !== -1 && (index === from || index === from + 1)) return null; |
| 81 | const rest = list.filter((number) => number !== dragged); |
| 82 | const at = from !== -1 && from < index ? index - 1 : index; |
| 83 | const clamped = Math.max(0, Math.min(at, rest.length)); |
| 84 | if (clamped < rest.length) return { before: rest[clamped]! }; |
| 85 | if (rest.length > 0) return { after: rest[rest.length - 1]! }; |
| 86 | return {}; |
| 87 | } |
| 88 | |
| 89 | /** Moving `number` one place up in `list`: before the one above it. Null at the top, or when it is not there. */ |
| 90 | export function moveUp(list: readonly number[], number: number): Position | null { |
| 91 | const at = list.indexOf(number); |
| 92 | return at > 0 ? { before: list[at - 1]! } : null; |
| 93 | } |
| 94 | |
| 95 | /** Moving `number` one place down in `list`: after the one below it. Null at the bottom, or when it is not there. */ |
| 96 | export function moveDown(list: readonly number[], number: number): Position | null { |
| 97 | const at = list.indexOf(number); |
| 98 | return at !== -1 && at < list.length - 1 ? { after: list[at + 1]! } : null; |
| 99 | } |
| 100 | |
| 101 | /** A move in the plan, as a row's menu, a drop or Triage's buttons make it. */ |
| 102 | export type Move = { number: number; horizon: Horizon } & Position; |
| 103 | |
| 104 | /** What the Plan page shows: the plan, as `getPlan` read it. */ |
| 105 | export type PlanView = Pick<Plan, "now" | "next" | "nextTotal" | "laterTotal" | "later" | "setAsideTotal" | "triageTotal">; |
| 106 | |
| 107 | /** |
| 108 | * The plan as it will be once `move` is made, to show before the work |
| 109 | * service answers: the item leaves wherever it was and goes where the |
| 110 | * move says, on Now or Next (or, past the top of Next shown, only into |
| 111 | * its count), or into the count of the horizon it went to. |
| 112 | */ |
| 113 | export function applyMove(view: PlanView, move: Move, from?: WorkItem): PlanView { |
| 114 | const found = view.now.find((item) => item.number === move.number) ?? view.next.find((item) => item.number === move.number) ?? from; |
| 115 | const now = view.now.filter((item) => item.number !== move.number); |
| 116 | const next = view.next.filter((item) => item.number !== move.number); |
| 117 | const out: PlanView = { ...view, now, next }; |
| 118 | if (found?.horizon === move.horizon && !view.now.includes(found) && !view.next.includes(found)) return view; |
| 119 | if (found?.horizon === "next") out.nextTotal -= 1; |
| 120 | if (found?.horizon === "later") { |
| 121 | out.laterTotal -= 1; |
| 122 | out.later = bump(out.later, originPath(found), -1); |
| 123 | } |
| 124 | if (found?.horizon === "triage") out.triageTotal -= 1; |
| 125 | if (move.horizon === "next") out.nextTotal += 1; |
| 126 | if (move.horizon === "later") { |
| 127 | out.laterTotal += 1; |
| 128 | if (found) out.later = bump(out.later, originPath(found), 1); |
| 129 | } |
| 130 | if (move.horizon === "set_aside") out.setAsideTotal += 1; |
| 131 | if (move.horizon === "triage") out.triageTotal += 1; |
| 132 | if (!found || (move.horizon !== "now" && move.horizon !== "next")) return out; |
| 133 | const moved: WorkItem = { ...found, horizon: move.horizon }; |
| 134 | const list = move.horizon === "now" ? now : next; |
| 135 | // Next shows only its top: one placed below all of it that is shown is only counted. |
| 136 | const whole = move.horizon === "now" || list.length >= out.nextTotal - 1; |
| 137 | let at = -1; |
| 138 | if (move.top) at = 0; |
| 139 | else if (move.before != null) at = list.findIndex((item) => item.number === move.before); |
| 140 | else if (move.after != null) { |
| 141 | const below = list.findIndex((item) => item.number === move.after); |
| 142 | at = below === -1 ? -1 : below + 1; |
| 143 | } |
| 144 | if (at !== -1) list.splice(at, 0, moved); |
| 145 | else if (whole) list.push(moved); |
| 146 | return out; |
| 147 | } |
| 148 | |
| 149 | /** Later's groups with `by` more (or fewer) in `repo`'s; a group left with none goes. */ |
| 150 | function bump(groups: readonly PlanGroup[], repo: string | null, by: number): PlanGroup[] { |
| 151 | const same = (group: PlanGroup) => (group.repo ?? "").toLowerCase() === (repo ?? "").toLowerCase(); |
| 152 | if (!groups.some(same)) return by > 0 ? [...groups, { repo, count: by }] : [...groups]; |
| 153 | return groups.map((group) => (same(group) ? { ...group, count: group.count + by } : group)).filter((group) => group.count > 0); |
| 154 | } |
| 155 | |
| 156 | /** The move a form posts (`moveForm`'s fields), or null when it is not one. */ |
| 157 | export function moveFrom(form: Fields): Move | null { |
| 158 | const number = Number(form.get("number")); |
| 159 | const horizon = HORIZONS.find((one) => one === form.get("horizon")); |
| 160 | if (!Number.isInteger(number) || number < 1 || !horizon) return null; |
| 161 | if (form.get("top") === "1") return { number, horizon, top: true }; |
| 162 | const before = Number(form.get("before")); |
| 163 | if (form.has("before") && before > 0) return { number, horizon, before }; |
| 164 | const after = Number(form.get("after")); |
| 165 | if (form.has("after") && after > 0) return { number, horizon, after }; |
| 166 | return { number, horizon }; |
| 167 | } |
| 168 | |
| 169 | /** The repository a work item is filed in (its origin), by path; null for one filed in the workspace. */ |
| 170 | export function originPath(item: Pick<WorkItem, "repos">): string | null { |
| 171 | return item.repos.find((repo) => repo.role === "origin")?.path || null; |
| 172 | } |
| 173 | |
| 174 | /** The moves a row's menu offers, given where it is and whether the viewer may move work into or out of Now. */ |
| 175 | export function menuHorizons(horizon: Horizon, canNow: boolean): Horizon[] { |
| 176 | return HORIZONS.filter((one) => one !== horizon && (canNow || (one !== "now" && horizon !== "now"))); |
| 177 | } |
| 178 | |
| 179 | /** Who or what is on a work item: people (assignees, claimants, whoever runs a live session on it), and its live sessions. */ |
| 180 | export type OnIt = { people: string[]; sessions: WorkspaceSession[] }; |
| 181 | |
| 182 | /** Who is on each of `items`, by number, from the workspace's active claims and live sessions, as the board says. */ |
| 183 | export function onItems(items: readonly WorkItem[], claims: readonly Claim[], sessions: readonly WorkspaceSession[]): Record<number, OnIt> { |
| 184 | const on: Record<number, OnIt> = {}; |
| 185 | for (const item of items) { |
| 186 | const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active"); |
| 187 | const live = sessions.filter((session) => session.item === item.number && session.state !== "ended"); |
| 188 | const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))]; |
| 189 | on[item.number] = { people: [...new Set(people)], sessions: live }; |
| 190 | } |
| 191 | return on; |
| 192 | } |
| 193 | |
| 194 | /** |
| 195 | * The work items that `items` wait on and whose state is not known from |
| 196 | * `open` (the open ones already read), each once, the first `max`: the |
| 197 | * ones to look up. |
| 198 | */ |
| 199 | export function blockersToRead(items: readonly Pick<WorkItem, "blockedBy">[], open: ReadonlySet<number>, max = 20): number[] { |
| 200 | const wanted = new Set<number>(); |
| 201 | for (const item of items) for (const number of item.blockedBy) if (!open.has(number)) wanted.add(number); |
| 202 | return [...wanted].slice(0, max); |
| 203 | } |
| 204 | |
| 205 | /** What an item waits on: the work items it is blocked by that are not known to be closed. */ |
| 206 | export function waitingOn(item: Pick<WorkItem, "blockedBy">, closed: ReadonlySet<number>): number[] { |
| 207 | return item.blockedBy.filter((number) => !closed.has(number)); |
| 208 | } |
| 209 | |
| 210 | /** "waiting on #12", "waiting on #12, #14". */ |
| 211 | export function waitingLabel(numbers: readonly number[]): string { |
| 212 | return `waiting on ${numbers.map((number) => `#${number}`).join(", ")}`; |
| 213 | } |
| 214 | |
| 215 | /** What each appetite means, in a few words. */ |
| 216 | export const APPETITE_HINTS: Record<Appetite, string> = { |
| 217 | small: "Small: one session", |
| 218 | medium: "Medium: a few sessions, about a week", |
| 219 | large: "Large: a phase, broken into smaller items before it moves to Now", |
| 220 | }; |
| 221 | |
| 222 | /** The appetites at once, for a hint beside a choice of them. */ |
| 223 | export const APPETITE_SUMMARY = "S: one session. M: a few sessions, about a week. L: a phase, broken down before it moves to Now."; |
| 224 | |
| 225 | /** What each horizon is for, in a few words. */ |
| 226 | export const HORIZON_HINTS: Record<Horizon, string> = { |
| 227 | triage: "New, and not placed yet", |
| 228 | now: "What the workspace is on", |
| 229 | next: "Ranked: the top is taken first", |
| 230 | later: "Wanted, not ranked", |
| 231 | set_aside: "Out of every view but search", |
| 232 | }; |
| 233 | |
| 234 | /** The most a budget may be, in dollars, as the work service allows. */ |
| 235 | export const MAX_BUDGET_USD = 100_000; |
| 236 | |
| 237 | /** A form's fields, as `FormData` has them. */ |
| 238 | type Fields = { get(name: string): FormDataEntryValue | null; has(name: string): boolean }; |
| 239 | |
| 240 | /** |
| 241 | * A move in the plan from a form: `horizon`, then one of `before`, `after` |
| 242 | * (work item numbers) or `top=1`; `appetite` (`small`, `medium`, `large`, |
| 243 | * or `none` to clear it); `budget` in dollars (empty clears it); `urgent` |
| 244 | * (`1` or `0`). Only the fields given change. A string when the form says |
| 245 | * something the plan cannot take. |
| 246 | */ |
| 247 | export function planInput(form: Fields): PlanWorkItemInput | string { |
| 248 | const input: PlanWorkItemInput = {}; |
| 249 | if (form.has("horizon")) { |
| 250 | const horizon = HORIZONS.find((one) => one === form.get("horizon")); |
| 251 | if (!horizon) return "Choose where it goes: Triage, Now, Next, Later or Set aside."; |
| 252 | input.horizon = horizon; |
| 253 | } |
| 254 | const number = (name: string) => { |
| 255 | const value = Number(form.get(name)); |
| 256 | return Number.isInteger(value) && value > 0 ? value : null; |
| 257 | }; |
| 258 | if (form.get("top") === "1") input.top = true; |
| 259 | else if (form.has("before")) { |
| 260 | const before = number("before"); |
| 261 | if (before == null) return "That position is not a work item."; |
| 262 | input.before = before; |
| 263 | } else if (form.has("after")) { |
| 264 | const after = number("after"); |
| 265 | if (after == null) return "That position is not a work item."; |
| 266 | input.after = after; |
| 267 | } |
| 268 | if (form.has("appetite")) { |
| 269 | const value = String(form.get("appetite")); |
| 270 | if (value === "none" || value === "") input.appetite = null; |
| 271 | else { |
| 272 | const appetite = APPETITES.find((one) => one === value); |
| 273 | if (!appetite) return "Choose an appetite: S, M or L."; |
| 274 | input.appetite = appetite; |
| 275 | } |
| 276 | } |
| 277 | if (form.has("budget")) { |
| 278 | const budget = parseBudget(String(form.get("budget") ?? "")); |
| 279 | if (typeof budget === "string") return budget; |
| 280 | input.budget_usd = budget; |
| 281 | } |
| 282 | if (form.has("urgent")) input.urgent = form.get("urgent") === "1"; |
| 283 | return input; |
| 284 | } |
| 285 | |
| 286 | /** A budget as someone typed it (`40`, `$12.50`, `1,200`): dollars, null for none, or why it is not one. */ |
| 287 | export function parseBudget(text: string): number | null | string { |
| 288 | const tidy = text.replace(/[$,\s]/g, ""); |
| 289 | if (!tidy) return null; |
| 290 | const value = Number(tidy); |
| 291 | if (!Number.isFinite(value) || value <= 0 || value > MAX_BUDGET_USD) { |
| 292 | return `A budget is a number of dollars above 0 and up to ${MAX_BUDGET_USD.toLocaleString("en-US")}.`; |
| 293 | } |
| 294 | return Math.round(value * 100) / 100; |
| 295 | } |
| 296 | |
| 297 | /** A budget as the plan shows it: `$40`, `$12.50`. */ |
| 298 | export function budgetText(budget: number): string { |
| 299 | return `$${budget.toLocaleString("en-US", { minimumFractionDigits: Number.isInteger(budget) ? 0 : 2, maximumFractionDigits: 2 })}`; |
| 300 | } |
| 301 | |
| 302 | /** A move as the form its fetcher posts. */ |
| 303 | export function moveForm(move: Move): Record<string, string> { |
| 304 | const form: Record<string, string> = { action: "plan", number: String(move.number), horizon: move.horizon }; |
| 305 | if (move.top) form.top = "1"; |
| 306 | else if (move.before != null) form.before = String(move.before); |
| 307 | else if (move.after != null) form.after = String(move.after); |
| 308 | return form; |
| 309 | } |
| 310 | |
| 311 | /** How many more there are than are shown, as "n more"; null for none. */ |
| 312 | export function moreText(shown: number, total: number): string | null { |
| 313 | return total > shown ? `${(total - shown).toLocaleString("en-US")} more` : null; |
| 314 | } |