| 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, Hill, Horizon, Plan, PlanGroup, PlanSuggestion, 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 | /** Every stage of a hill, in the order work goes over it (the contracts' `HILLS`). */ |
| 20 | export const HILL_STEPS: readonly Hill[] = ["figuring_out", "decided", "building", "landing"]; |
| 21 | |
| 22 | /** The Plan, Work's first page. */ |
| 23 | export function planPath(workspace: string, query = ""): string { |
| 24 | return `${workPath(workspace)}${query ? `?${query}` : ""}`; |
| 25 | } |
| 26 | |
| 27 | /** Triage: new work items nobody has placed yet. */ |
| 28 | export function triagePath(workspace: string): string { |
| 29 | return workPath(workspace, "triage"); |
| 30 | } |
| 31 | |
| 32 | /** All items: the Work list, with its filters (`state=closed&label=bug`). */ |
| 33 | export function allItemsPath(workspace: string, query = ""): string { |
| 34 | return `${workPath(workspace, "all")}${query ? `?${query}` : ""}`; |
| 35 | } |
| 36 | |
| 37 | /** The All items list of what is set aside. */ |
| 38 | export function setAsidePath(workspace: string): string { |
| 39 | return allItemsPath(workspace, new URLSearchParams({ q: "horizon:set_aside" }).toString()); |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * The query parameters that belong to the Work list (lib/work-items.ts |
| 44 | * `workFilters`, and the paging and sort it keeps): `-/work` with any of |
| 45 | * them is an old link to the list, which is at `-/work/all` now. |
| 46 | */ |
| 47 | export const LIST_PARAMS = ["q", "label", "milestone", "assignee", "state", "repo", "role", "author", "horizon", "before", "sort", "page"] as const; |
| 48 | |
| 49 | /** Where `-/work` with this query leads instead of the Plan: the list with the same query, or null to show the Plan. */ |
| 50 | export function listRedirect(workspace: string, query: URLSearchParams): string | null { |
| 51 | const kept = new URLSearchParams(query); |
| 52 | kept.delete("_routes"); |
| 53 | kept.delete("index"); |
| 54 | if (!LIST_PARAMS.some((key) => kept.has(key))) return null; |
| 55 | return allItemsPath(workspace, kept.toString()); |
| 56 | } |
| 57 | |
| 58 | /** How full Now is: its share of the cap (at most 1, for the bar) and whether it is at or past it. */ |
| 59 | export type CapState = { count: number; cap: number; share: number; full: boolean; over: boolean }; |
| 60 | |
| 61 | export function capState(count: number, cap: number): CapState { |
| 62 | const limit = Math.max(1, cap); |
| 63 | return { count, cap: limit, share: Math.min(1, count / limit), full: count >= limit, over: count > limit }; |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * Where a move puts a work item among another horizon's (or its own) |
| 68 | * items: before or after another, first, or (none set) where the horizon |
| 69 | * puts it. At most one is set. |
| 70 | */ |
| 71 | export type Position = { before?: number; after?: number; top?: true }; |
| 72 | |
| 73 | /** |
| 74 | * The position for dropping `dragged` into `list` (the numbers as shown, |
| 75 | * top first, which may include `dragged` itself) at `index`, the gap it |
| 76 | * lands in (0 is above the first, `list.length` below the last): before |
| 77 | * the row it lands above, else after the last, else none (an empty list, |
| 78 | * where the horizon alone says where). Null when it lands where it is. |
| 79 | */ |
| 80 | export function dropPosition(list: readonly number[], index: number, dragged: number): Position | null { |
| 81 | const from = list.indexOf(dragged); |
| 82 | if (from !== -1 && (index === from || index === from + 1)) return null; |
| 83 | const rest = list.filter((number) => number !== dragged); |
| 84 | const at = from !== -1 && from < index ? index - 1 : index; |
| 85 | const clamped = Math.max(0, Math.min(at, rest.length)); |
| 86 | if (clamped < rest.length) return { before: rest[clamped]! }; |
| 87 | if (rest.length > 0) return { after: rest[rest.length - 1]! }; |
| 88 | return {}; |
| 89 | } |
| 90 | |
| 91 | /** Moving `number` one place up in `list`: before the one above it. Null at the top, or when it is not there. */ |
| 92 | export function moveUp(list: readonly number[], number: number): Position | null { |
| 93 | const at = list.indexOf(number); |
| 94 | return at > 0 ? { before: list[at - 1]! } : null; |
| 95 | } |
| 96 | |
| 97 | /** Moving `number` one place down in `list`: after the one below it. Null at the bottom, or when it is not there. */ |
| 98 | export function moveDown(list: readonly number[], number: number): Position | null { |
| 99 | const at = list.indexOf(number); |
| 100 | return at !== -1 && at < list.length - 1 ? { after: list[at + 1]! } : null; |
| 101 | } |
| 102 | |
| 103 | /** A move in the plan, as a row's menu, a drop or Triage's buttons make it. */ |
| 104 | export type Move = { number: number; horizon: Horizon } & Position; |
| 105 | |
| 106 | /** What the Plan page shows: the plan, as `getPlan` read it. */ |
| 107 | export type PlanView = Pick<Plan, "now" | "next" | "nextTotal" | "laterTotal" | "later" | "setAsideTotal" | "triageTotal">; |
| 108 | |
| 109 | /** |
| 110 | * The plan as it will be once `move` is made, to show before the work |
| 111 | * service answers: the item leaves wherever it was and goes where the |
| 112 | * move says, on Now or Next (or, past the top of Next shown, only into |
| 113 | * its count), or into the count of the horizon it went to. |
| 114 | */ |
| 115 | export function applyMove(view: PlanView, move: Move, from?: WorkItem): PlanView { |
| 116 | const found = view.now.find((item) => item.number === move.number) ?? view.next.find((item) => item.number === move.number) ?? from; |
| 117 | const now = view.now.filter((item) => item.number !== move.number); |
| 118 | const next = view.next.filter((item) => item.number !== move.number); |
| 119 | const out: PlanView = { ...view, now, next }; |
| 120 | if (found?.horizon === move.horizon && !view.now.includes(found) && !view.next.includes(found)) return view; |
| 121 | if (found?.horizon === "next") out.nextTotal -= 1; |
| 122 | if (found?.horizon === "later") { |
| 123 | out.laterTotal -= 1; |
| 124 | out.later = bump(out.later, originPath(found), -1); |
| 125 | } |
| 126 | if (found?.horizon === "triage") out.triageTotal -= 1; |
| 127 | if (move.horizon === "next") out.nextTotal += 1; |
| 128 | if (move.horizon === "later") { |
| 129 | out.laterTotal += 1; |
| 130 | if (found) out.later = bump(out.later, originPath(found), 1); |
| 131 | } |
| 132 | if (move.horizon === "set_aside") out.setAsideTotal += 1; |
| 133 | if (move.horizon === "triage") out.triageTotal += 1; |
| 134 | if (!found || (move.horizon !== "now" && move.horizon !== "next")) return out; |
| 135 | const moved: WorkItem = { ...found, horizon: move.horizon }; |
| 136 | const list = move.horizon === "now" ? now : next; |
| 137 | // Next shows only its top: one placed below all of it that is shown is only counted. |
| 138 | const whole = move.horizon === "now" || list.length >= out.nextTotal - 1; |
| 139 | let at = -1; |
| 140 | if (move.top) at = 0; |
| 141 | else if (move.before != null) at = list.findIndex((item) => item.number === move.before); |
| 142 | else if (move.after != null) { |
| 143 | const below = list.findIndex((item) => item.number === move.after); |
| 144 | at = below === -1 ? -1 : below + 1; |
| 145 | } |
| 146 | if (at !== -1) list.splice(at, 0, moved); |
| 147 | else if (whole) list.push(moved); |
| 148 | return out; |
| 149 | } |
| 150 | |
| 151 | /** Later's groups with `by` more (or fewer) in `repo`'s; a group left with none goes. */ |
| 152 | function bump(groups: readonly PlanGroup[], repo: string | null, by: number): PlanGroup[] { |
| 153 | const same = (group: PlanGroup) => (group.repo ?? "").toLowerCase() === (repo ?? "").toLowerCase(); |
| 154 | if (!groups.some(same)) return by > 0 ? [...groups, { repo, count: by }] : [...groups]; |
| 155 | return groups.map((group) => (same(group) ? { ...group, count: group.count + by } : group)).filter((group) => group.count > 0); |
| 156 | } |
| 157 | |
| 158 | /** The move a form posts (`moveForm`'s fields), or null when it is not one. */ |
| 159 | export function moveFrom(form: Fields): Move | null { |
| 160 | const number = Number(form.get("number")); |
| 161 | const horizon = HORIZONS.find((one) => one === form.get("horizon")); |
| 162 | if (!Number.isInteger(number) || number < 1 || !horizon) return null; |
| 163 | if (form.get("top") === "1") return { number, horizon, top: true }; |
| 164 | const before = Number(form.get("before")); |
| 165 | if (form.has("before") && before > 0) return { number, horizon, before }; |
| 166 | const after = Number(form.get("after")); |
| 167 | if (form.has("after") && after > 0) return { number, horizon, after }; |
| 168 | return { number, horizon }; |
| 169 | } |
| 170 | |
| 171 | /** The repository a work item is filed in (its origin), by path; null for one filed in the workspace. */ |
| 172 | export function originPath(item: Pick<WorkItem, "repos">): string | null { |
| 173 | return item.repos.find((repo) => repo.role === "origin")?.path || null; |
| 174 | } |
| 175 | |
| 176 | /** The moves a row's menu offers, given where it is and whether the viewer may move work into or out of Now. */ |
| 177 | export function menuHorizons(horizon: Horizon, canNow: boolean): Horizon[] { |
| 178 | return HORIZONS.filter((one) => one !== horizon && (canNow || (one !== "now" && horizon !== "now"))); |
| 179 | } |
| 180 | |
| 181 | /** Who or what is on a work item: people (assignees, claimants, whoever runs a live session on it), and its live sessions. */ |
| 182 | export type OnIt = { people: string[]; sessions: WorkspaceSession[] }; |
| 183 | |
| 184 | /** Who is on each of `items`, by number, from the workspace's active claims and live sessions, as the board says. */ |
| 185 | export function onItems(items: readonly WorkItem[], claims: readonly Claim[], sessions: readonly WorkspaceSession[]): Record<number, OnIt> { |
| 186 | const on: Record<number, OnIt> = {}; |
| 187 | for (const item of items) { |
| 188 | const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active"); |
| 189 | const live = sessions.filter((session) => session.item === item.number && session.state !== "ended"); |
| 190 | const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))]; |
| 191 | on[item.number] = { people: [...new Set(people)], sessions: live }; |
| 192 | } |
| 193 | return on; |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * The work items that `items` wait on and whose state is not known from |
| 198 | * `open` (the open ones already read), each once, the first `max`: the |
| 199 | * ones to look up. |
| 200 | */ |
| 201 | export function blockersToRead(items: readonly Pick<WorkItem, "blockedBy">[], open: ReadonlySet<number>, max = 20): number[] { |
| 202 | const wanted = new Set<number>(); |
| 203 | for (const item of items) for (const number of item.blockedBy) if (!open.has(number)) wanted.add(number); |
| 204 | return [...wanted].slice(0, max); |
| 205 | } |
| 206 | |
| 207 | /** What an item waits on: the work items it is blocked by that are not known to be closed. */ |
| 208 | export function waitingOn(item: Pick<WorkItem, "blockedBy">, closed: ReadonlySet<number>): number[] { |
| 209 | return item.blockedBy.filter((number) => !closed.has(number)); |
| 210 | } |
| 211 | |
| 212 | /** "waiting on #12", "waiting on #12, #14". */ |
| 213 | export function waitingLabel(numbers: readonly number[]): string { |
| 214 | return `waiting on ${numbers.map((number) => `#${number}`).join(", ")}`; |
| 215 | } |
| 216 | |
| 217 | /** What each appetite means, in a few words. */ |
| 218 | export const APPETITE_HINTS: Record<Appetite, string> = { |
| 219 | small: "Small: one session", |
| 220 | medium: "Medium: a few sessions, about a week", |
| 221 | large: "Large: a phase, broken into smaller items before it moves to Now", |
| 222 | }; |
| 223 | |
| 224 | /** The appetites at once, for a hint beside a choice of them. */ |
| 225 | export const APPETITE_SUMMARY = "S: one session. M: a few sessions, about a week. L: a phase, broken down before it moves to Now."; |
| 226 | |
| 227 | /** What each horizon is for, in a few words. */ |
| 228 | export const HORIZON_HINTS: Record<Horizon, string> = { |
| 229 | triage: "New, and not placed yet", |
| 230 | now: "What the workspace is on", |
| 231 | next: "Ranked: the top is taken first", |
| 232 | later: "Wanted, not ranked", |
| 233 | set_aside: "Out of every view but search", |
| 234 | }; |
| 235 | |
| 236 | /** The most a budget may be, in dollars, as the work service allows. */ |
| 237 | export const MAX_BUDGET_USD = 100_000; |
| 238 | |
| 239 | /** A form's fields, as `FormData` has them. */ |
| 240 | type Fields = { get(name: string): FormDataEntryValue | null; has(name: string): boolean }; |
| 241 | |
| 242 | /** |
| 243 | * A move in the plan from a form: `horizon`, then one of `before`, `after` |
| 244 | * (work item numbers) or `top=1`; `appetite` (`small`, `medium`, `large`, |
| 245 | * or `none` to clear it); `budget` in dollars (empty clears it); `urgent` |
| 246 | * (`1` or `0`). Only the fields given change. A string when the form says |
| 247 | * something the plan cannot take. |
| 248 | */ |
| 249 | export function planInput(form: Fields): PlanWorkItemInput | string { |
| 250 | const input: PlanWorkItemInput = {}; |
| 251 | if (form.has("horizon")) { |
| 252 | const horizon = HORIZONS.find((one) => one === form.get("horizon")); |
| 253 | if (!horizon) return "Choose where it goes: Triage, Now, Next, Later or Set aside."; |
| 254 | input.horizon = horizon; |
| 255 | } |
| 256 | const number = (name: string) => { |
| 257 | const value = Number(form.get(name)); |
| 258 | return Number.isInteger(value) && value > 0 ? value : null; |
| 259 | }; |
| 260 | if (form.get("top") === "1") input.top = true; |
| 261 | else if (form.has("before")) { |
| 262 | const before = number("before"); |
| 263 | if (before == null) return "That position is not a work item."; |
| 264 | input.before = before; |
| 265 | } else if (form.has("after")) { |
| 266 | const after = number("after"); |
| 267 | if (after == null) return "That position is not a work item."; |
| 268 | input.after = after; |
| 269 | } |
| 270 | if (form.has("appetite")) { |
| 271 | const value = String(form.get("appetite")); |
| 272 | if (value === "none" || value === "") input.appetite = null; |
| 273 | else { |
| 274 | const appetite = APPETITES.find((one) => one === value); |
| 275 | if (!appetite) return "Choose an appetite: S, M or L."; |
| 276 | input.appetite = appetite; |
| 277 | } |
| 278 | } |
| 279 | if (form.has("budget")) { |
| 280 | const budget = parseBudget(String(form.get("budget") ?? "")); |
| 281 | if (typeof budget === "string") return budget; |
| 282 | input.budget_usd = budget; |
| 283 | } |
| 284 | if (form.has("urgent")) input.urgent = form.get("urgent") === "1"; |
| 285 | return input; |
| 286 | } |
| 287 | |
| 288 | /** A budget as someone typed it (`40`, `$12.50`, `1,200`): dollars, null for none, or why it is not one. */ |
| 289 | export function parseBudget(text: string): number | null | string { |
| 290 | const tidy = text.replace(/[$,\s]/g, ""); |
| 291 | if (!tidy) return null; |
| 292 | const value = Number(tidy); |
| 293 | if (!Number.isFinite(value) || value <= 0 || value > MAX_BUDGET_USD) { |
| 294 | return `A budget is a number of dollars above 0 and up to ${MAX_BUDGET_USD.toLocaleString("en-US")}.`; |
| 295 | } |
| 296 | return Math.round(value * 100) / 100; |
| 297 | } |
| 298 | |
| 299 | /** A budget as the plan shows it: `$40`, `$12.50`. */ |
| 300 | export function budgetText(budget: number): string { |
| 301 | return `$${budget.toLocaleString("en-US", { minimumFractionDigits: Number.isInteger(budget) ? 0 : 2, maximumFractionDigits: 2 })}`; |
| 302 | } |
| 303 | |
| 304 | /** A move as the form its fetcher posts. */ |
| 305 | export function moveForm(move: Move): Record<string, string> { |
| 306 | const form: Record<string, string> = { action: "plan", number: String(move.number), horizon: move.horizon }; |
| 307 | if (move.top) form.top = "1"; |
| 308 | else if (move.before != null) form.before = String(move.before); |
| 309 | else if (move.after != null) form.after = String(move.after); |
| 310 | return form; |
| 311 | } |
| 312 | |
| 313 | /** How many more there are than are shown, as "n more"; null for none. */ |
| 314 | export function moreText(shown: number, total: number): string | null { |
| 315 | return total > shown ? `${(total - shown).toLocaleString("en-US")} more` : null; |
| 316 | } |
| 317 | |
| 318 | // --- How Now stands ----------------------------------------------------------- |
| 319 | |
| 320 | const DAY_MS = 86_400_000; |
| 321 | |
| 322 | /** Where `hill` is among its four stages: 0 (figuring it out) to 3 (landing). */ |
| 323 | export function hillStep(hill: Hill): number { |
| 324 | return Math.max(0, HILL_STEPS.indexOf(hill)); |
| 325 | } |
| 326 | |
| 327 | /** Whole days since `at` (RFC 3339), never fewer than 0; null when there is no time. */ |
| 328 | export function daysSince(at: string | null | undefined, now: number): number | null { |
| 329 | const time = at ? Date.parse(at) : Number.NaN; |
| 330 | if (Number.isNaN(time)) return null; |
| 331 | return Math.max(0, Math.floor((now - time) / DAY_MS)); |
| 332 | } |
| 333 | |
| 334 | /** |
| 335 | * What a quiet row says: "Quiet for 6 days", counted from the last time |
| 336 | * anything happened on it; "Quiet" alone when that is not known (or is |
| 337 | * today, as clocks differ). Null |
| 338 | * for one that is not quiet. |
| 339 | */ |
| 340 | export function quietText(progress: { quiet: boolean; activeAt: string | null }, now: number): string | null { |
| 341 | if (!progress.quiet) return null; |
| 342 | const days = daysSince(progress.activeAt, now); |
| 343 | if (days == null || days < 1) return "Quiet"; |
| 344 | return `Quiet for ${days === 1 ? "1 day" : `${days.toLocaleString("en-US")} days`}`; |
| 345 | } |
| 346 | |
| 347 | /** How each work item in Now stands, by number. */ |
| 348 | export function progressOf<P extends { number: number }>(progress: readonly P[]): Record<number, P> { |
| 349 | return Object.fromEntries(progress.map((one) => [one.number, one])); |
| 350 | } |
| 351 | |
| 352 | /** Days with nothing happening before a work item in Now has gone quiet (the contracts' `QUIET_DAYS`). */ |
| 353 | export const QUIET_AFTER_DAYS = 5; |
| 354 | |
| 355 | /** The week's line about Now going quiet; null for none. */ |
| 356 | export function quietLine(count: number): string | null { |
| 357 | if (count <= 0) return null; |
| 358 | const one = count === 1; |
| 359 | return `${count.toLocaleString("en-US")} in Now ${one ? "has" : "have"} gone quiet, with nothing happening on ${one ? "it" : "them"} for ${QUIET_AFTER_DAYS} days or more.`; |
| 360 | } |
| 361 | |
| 362 | /** The week's line about letting go; null for none set aside lately. */ |
| 363 | export function setAsideLine(count: number): string | null { |
| 364 | if (count <= 0) return null; |
| 365 | return `${count.toLocaleString("en-US")} set aside in the last 30 days, untouched for 90 days in Later`; |
| 366 | } |
| 367 | |
| 368 | /** Triage's banner on the Plan: how many wait, and how many of them have waited more than a week. */ |
| 369 | export function triageLine(total: number, waiting: number): string { |
| 370 | const wait = total === 1 ? "1 new work item waits" : `${total.toLocaleString("en-US")} new work items wait`; |
| 371 | const long = |
| 372 | waiting <= 0 |
| 373 | ? "" |
| 374 | : waiting >= total |
| 375 | ? total === 1 |
| 376 | ? " It has waited more than a week." |
| 377 | : " All have waited more than a week." |
| 378 | : ` ${waiting.toLocaleString("en-US")} of them have waited more than a week.`; |
| 379 | return `${wait} in Triage to be placed.${long}`; |
| 380 | } |
| 381 | |
| 382 | // --- Triage's suggestions ------------------------------------------------------- |
| 383 | |
| 384 | /** A suggestion in a few words: "Next · M". */ |
| 385 | export function suggestionText(suggestion: Pick<PlanSuggestion, "horizon" | "appetite">): string { |
| 386 | const horizon = { triage: "Triage", now: "Now", next: "Next", later: "Later", set_aside: "Set aside" }[suggestion.horizon]; |
| 387 | const size = suggestion.appetite ? ` · ${suggestion.appetite === "small" ? "S" : suggestion.appetite === "medium" ? "M" : "L"}` : ""; |
| 388 | return `${horizon}${size}`; |
| 389 | } |
| 390 | |
| 391 | /** |
| 392 | * Accepting a suggestion, as the form a fetcher posts (`planInput` reads |
| 393 | * it): its horizon, where the horizon puts new work (the bottom of Next), |
| 394 | * and its appetite when it has one. |
| 395 | */ |
| 396 | export function acceptForm(suggestion: Pick<PlanSuggestion, "horizon" | "appetite">): Record<string, string> { |
| 397 | const form: Record<string, string> = { horizon: suggestion.horizon }; |
| 398 | if (suggestion.appetite) form.appetite = suggestion.appetite; |
| 399 | return form; |
| 400 | } |
| 401 | |
| 402 | /** Whether the viewer may accept a suggestion: moving into Now takes more than placing work elsewhere. */ |
| 403 | export function canAccept(suggestion: Pick<PlanSuggestion, "horizon">, canNow: boolean): boolean { |
| 404 | return suggestion.horizon !== "triage" && (suggestion.horizon !== "now" || canNow); |
| 405 | } |
| 406 | |
| 407 | /** The comment a work item closed as a duplicate gets. */ |
| 408 | export function duplicateComment(of: number): string { |
| 409 | return `Duplicate of #${of}`; |
| 410 | } |