@harborppm/cli 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.mjs CHANGED
@@ -2279,2272 +2279,2395 @@ var init_premortem = __esmMin((() => {
2279
2279
  }));
2280
2280
 
2281
2281
  //#endregion
2282
- //#region src/lib/charter-lines.ts
2283
- /** Nothing written on any of the five lines. */
2284
- function charterEmpty(charter) {
2285
- return !charter || CHARTER_FIELDS.every((f) => !clean(charter[f]));
2282
+ //#region src/lib/soundings.ts
2283
+ /** Why a number can't be an answer, or null when it can: half days up to SOUNDING_MAX_DAYS. */
2284
+ function daysProblem(days) {
2285
+ if (!Number.isFinite(days) || days <= 0) return "Give a number of days above 0, like 3 or 0.5.";
2286
+ if (days > 250) return `Keep it to ${250} working days or fewer.`;
2287
+ if (Math.round(days * 2) !== days * 2) return "Use whole or half days, like 3 or 2.5.";
2288
+ return null;
2289
+ }
2290
+ /** Each asked person's latest answer to the task's current sounding, oldest first. */
2291
+ function answersFor(reflections, task) {
2292
+ const s = task.sounding;
2293
+ if (!s) return [];
2294
+ const crew = new Set(s.crewIds);
2295
+ const latest = /* @__PURE__ */ new Map();
2296
+ for (const r of reflections ?? []) {
2297
+ if (r.kind !== "sounding" || r.taskId !== task.id || typeof r.days !== "number") continue;
2298
+ if (r.createdAt < s.askedAt || !crew.has(r.authorId)) continue;
2299
+ const had = latest.get(r.authorId);
2300
+ if (!had || r.createdAt >= had.createdAt) latest.set(r.authorId, r);
2301
+ }
2302
+ return [...latest.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2303
+ }
2304
+ /** Middle of the guesses, to the half day. */
2305
+ function middleOf(days) {
2306
+ if (days.length === 0) return null;
2307
+ const sorted = [...days].sort((a, b) => a - b);
2308
+ const mid = Math.floor(sorted.length / 2);
2309
+ const m = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
2310
+ return Math.round(m * 2) / 2;
2286
2311
  }
2287
2312
  /**
2288
- * The charter after a change to some of its lines: trimmed, and null once
2289
- * every line is empty so a cleared charter reads as never written.
2313
+ * Guesses far enough apart to talk about first: the highest at least twice
2314
+ * the lowest and three or more days from it. 3 against 5 is agreement; 2
2315
+ * against 15, or 5 against 10, is two people seeing different work.
2290
2316
  */
2291
- function withCharter(current, patch, by, at) {
2292
- const next = {
2293
- ...current ?? {},
2294
- updatedAt: at,
2295
- updatedById: by
2296
- };
2297
- for (const field of CHARTER_FIELDS) {
2298
- if (!(field in patch)) continue;
2299
- const text = (patch[field] ?? "").trim();
2300
- if (text) next[field] = text;
2301
- else delete next[field];
2302
- }
2303
- return charterEmpty(next) ? null : next;
2317
+ function isWide(days) {
2318
+ if (days.length < 2) return false;
2319
+ const low = Math.min(...days);
2320
+ const high = Math.max(...days);
2321
+ return high - low >= 3 && high >= low * 2;
2304
2322
  }
2305
- var CHARTER_FIELDS, clean;
2306
- var init_charter_lines = __esmMin((() => {
2307
- CHARTER_FIELDS = [
2308
- "why",
2309
- "inScope",
2310
- "outScope",
2311
- "doneWhen",
2312
- "callsBy"
2313
- ];
2314
- clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
2315
- }));
2316
-
2317
- //#endregion
2318
- //#region src/lib/charter.ts
2319
- init_indexes();
2320
- init_date_fns();
2321
- init_date_text();
2322
- init_premortem();
2323
- init_charter_lines();
2324
- const CHARTER_LABEL = {
2325
- why: "Why",
2326
- inScope: "In",
2327
- outScope: "Out",
2328
- doneWhen: "Done when",
2329
- callsBy: "Calls made by"
2330
- };
2331
- /** Each line's name for agents and the CLI's --json: plain words, no "Scope". */
2332
- const CHARTER_KEY = {
2333
- why: "why",
2334
- inScope: "in",
2335
- outScope: "out",
2336
- doneWhen: "doneWhen",
2337
- callsBy: "callsBy"
2338
- };
2339
- /** The lines written so far, in order. */
2340
- function charterLines(charter) {
2341
- if (!charter) return [];
2342
- return CHARTER_FIELDS.map((field) => ({
2343
- field,
2344
- label: CHARTER_LABEL[field],
2345
- text: clean(charter[field])
2346
- })).filter((l) => l.text);
2323
+ /** Where a task's sounding stands, or null when nobody asked. */
2324
+ function readSounding(task, reflections, now) {
2325
+ const s = task.sounding;
2326
+ if (!s) return null;
2327
+ const answers = answersFor(reflections, task);
2328
+ const by = new Set(answers.map((a) => a.authorId));
2329
+ const waitingOn = s.crewIds.filter((id) => !by.has(id));
2330
+ const early = Boolean(s.shownAt && Date.parse(s.shownAt) <= now.getTime());
2331
+ const shown = answers.length > 0 && (waitingOn.length === 0 || early);
2332
+ const days = answers.map((a) => a.days);
2333
+ const wide = shown && isWide(days);
2334
+ const middle = middleOf(days);
2335
+ const settled = s.settledDays ?? null;
2336
+ return {
2337
+ sounding: s,
2338
+ crewIds: s.crewIds,
2339
+ answers,
2340
+ waitingOn,
2341
+ shown,
2342
+ low: days.length ? Math.min(...days) : null,
2343
+ high: days.length ? Math.max(...days) : null,
2344
+ middle,
2345
+ wide,
2346
+ estimate: settled ?? (shown && !wide ? middle : null)
2347
+ };
2347
2348
  }
2348
- function charterFilled(charter) {
2349
- return charterLines(charter).length;
2349
+ /** What one person may see: their own answer always; everyone's once shown. */
2350
+ function visibleAnswers(read, personId) {
2351
+ return read.shown ? read.answers : read.answers.filter((a) => a.authorId === personId);
2350
2352
  }
2351
- /** A week after the start, an empty charter is worth a nudge. */
2352
- const CHARTER_GRACE_DAYS = 7;
2353
2353
  /**
2354
- * A project whose charter is still blank a week after it started, while
2355
- * it's still running. Done and paused ones are left alone.
2354
+ * The answers the forecast draws from for an open task: the settled number
2355
+ * alone once there is one, otherwise every answer in so far (the spread is
2356
+ * the point, and the forecast names nobody's guess).
2356
2357
  */
2357
- function charterOverdue(project, today) {
2358
- if (project.health === "done" || project.health === "paused") return null;
2359
- if (!charterEmpty(project.charter)) return null;
2360
- const days = differenceInCalendarDays(parseIso(today), parseIso(project.startDate.slice(0, 10)));
2361
- return days >= 7 ? days : null;
2358
+ function soundingDraws(task, reflections) {
2359
+ const s = task.sounding;
2360
+ if (!s) return [];
2361
+ if (s.settledDays != null) return [s.settledDays];
2362
+ return answersFor(reflections, task).map((a) => a.days);
2362
2363
  }
2363
- /** The items of an In or Out line: split on commas, semicolons, "and" lists and new lines. */
2364
- function charterItems(text) {
2365
- return (text ?? "").split(/[\n,;•]+/).map((s) => s.replace(/^\s*(?:and|or|-|\*)\s+/i, "").trim()).filter((s) => contentWords(s).size > 0);
2364
+ /**
2365
+ * Calendar days from `from` until `days` working days have passed: Monday
2366
+ * to Friday, or the days `works` says (a person's holidays and time away,
2367
+ * src/lib/holidays.ts). Gives up after two years.
2368
+ */
2369
+ function workingDaysAhead(days, from, works) {
2370
+ let left = Math.ceil(days);
2371
+ let d = 0;
2372
+ while (left > 0 && d < 730) {
2373
+ d += 1;
2374
+ const at = addDays(from, d);
2375
+ const wd = at.getDay();
2376
+ if (works ? works(at) : wd !== 0 && wd !== 6) left -= 1;
2377
+ }
2378
+ return d;
2366
2379
  }
2367
- /** True when a task's title names an item: every meaningful word of a short item, or two of a longer one. */
2368
- function names(item, title) {
2369
- let shared = 0;
2370
- for (const w of item) if (title.has(w)) shared += 1;
2371
- return item.size <= 2 ? shared === item.size : shared >= 2;
2380
+ /** "half a day", "1 day", "2.5 days". */
2381
+ function daysText(days) {
2382
+ if (days === .5) return "half a day";
2383
+ return `${days} day${days === 1 ? "" : "s"}`;
2372
2384
  }
2373
2385
  /**
2374
- * Tasks whose titles name something the charter put out of scope, newest
2375
- * first: the "I thought that was in" list. Tasks made before the charter
2376
- * was last written are left out when `since` is given, so writing an Out
2377
- * line about work already on the board doesn't flag it.
2386
+ * Who a new sounding asks unless you pick: you, the task's owner, and the
2387
+ * people with open work on its project, agents left out.
2378
2388
  */
2379
- function outOfScope(charter, tasks, opts = {}) {
2380
- const items = charterItems(charter?.outScope).map((item) => ({
2381
- item,
2382
- words: contentWords(item)
2383
- }));
2384
- if (items.length === 0) return [];
2385
- const out = [];
2386
- for (const task of tasks) {
2387
- if (opts.since && task.createdAt < opts.since) continue;
2388
- const title = contentWords(task.title);
2389
- const hit = items.find((i) => names(i.words, title));
2390
- if (hit) out.push({
2391
- task,
2392
- item: hit.item
2393
- });
2389
+ function suggestedCrew(data, task, meId) {
2390
+ const people = new Map(data.people.filter((p) => p.kind !== "agent").map((p) => [p.id, p]));
2391
+ const out = /* @__PURE__ */ new Set();
2392
+ if (people.has(meId)) out.add(meId);
2393
+ if (task.assigneeId && people.has(task.assigneeId)) out.add(task.assigneeId);
2394
+ for (const t of data.tasks) {
2395
+ if (out.size >= 6) break;
2396
+ if (t.projectId !== task.projectId || t.status === "done" || !t.assigneeId) continue;
2397
+ if (people.has(t.assigneeId)) out.add(t.assigneeId);
2394
2398
  }
2395
- return out.sort((a, b) => b.task.createdAt.localeCompare(a.task.createdAt));
2399
+ return [...out];
2396
2400
  }
2397
- /** The voyage log's look at the charter: did the In list get done, did the Out list stay out. */
2398
- function charterCheck(charter, tasks) {
2399
- if (charterEmpty(charter)) return null;
2400
- const finished = tasks.filter((t) => t.status === "done").map((t) => contentWords(t.title));
2401
- const delivered = charterItems(charter.inScope).map((item) => {
2402
- const words = contentWords(item);
2403
- return {
2404
- item,
2405
- done: finished.filter((title) => names(words, title)).length
2406
- };
2407
- });
2408
- return {
2409
- doneWhen: clean(charter.doneWhen) || null,
2410
- delivered,
2411
- crept: outOfScope(charter, tasks),
2412
- outItems: charterItems(charter.outScope).length
2413
- };
2401
+ /** "Close enough: about 4 days", or why it needs talking through. */
2402
+ function soundingVerdict(read) {
2403
+ if (read.sounding.settledDays != null) return `Settled on ${daysText(read.sounding.settledDays)}.`;
2404
+ if (read.wide) return `Talk about this first: guesses run from ${daysText(read.low)} to ${daysText(read.high)}, so someone sees work the others don't.`;
2405
+ return `Close enough: about ${daysText(read.estimate)}.`;
2414
2406
  }
2415
- /** One sentence for the voyage log's story; null when there's no charter. */
2416
- function charterSentence(check) {
2417
- if (!check) return null;
2418
- const bits = [];
2419
- if (check.delivered.length) {
2420
- const missing = check.delivered.filter((d) => d.done === 0).map((d) => d.item);
2421
- bits.push(missing.length === 0 ? "every item the charter put in has finished work against it" : `${check.delivered.length - missing.length} of ${check.delivered.length} items the charter put in have finished work against them (not: ${missing.join(", ")})`);
2422
- }
2423
- if (check.crept.length) {
2424
- const items = [...new Set(check.crept.map((c) => c.item))];
2425
- bits.push(`${check.crept.length} task${check.crept.length === 1 ? "" : "s"} touched what it put out (${items.join(", ")})`);
2426
- } else if (check.outItems > 0) bits.push("nothing it put out crept in");
2427
- if (!bits.length) return null;
2428
- return `Against the charter: ${bits.join("; ")}.`;
2407
+ /** Open tasks whose sounding waits on this person's answer. */
2408
+ function soundingsWaitingOn(data, personId, now) {
2409
+ return data.tasks.filter((t) => {
2410
+ if (t.status === "done" || !t.sounding?.crewIds.includes(personId)) return false;
2411
+ return readSounding(t, data.reflections, now).waitingOn.includes(personId);
2412
+ });
2429
2413
  }
2414
+ var SOUNDING_MAX_DAYS;
2415
+ var init_soundings = __esmMin((() => {
2416
+ init_date_fns();
2417
+ SOUNDING_MAX_DAYS = 250;
2418
+ }));
2430
2419
 
2431
2420
  //#endregion
2432
- //#region src/lib/voyage-log.ts
2433
- init_date_fns();
2434
- init_blockers();
2435
- init_date_text();
2436
- init_premortem();
2437
- const day$6 = (iso) => parseIso(iso.slice(0, 10));
2438
- const between = (a, b) => differenceInCalendarDays(day$6(b), day$6(a));
2439
- /** When the project was marked Done: its last move to Done in its health history. */
2440
- function finishedOn(project, tasks) {
2441
- const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
2442
- if (marked) return marked.at.slice(0, 10);
2443
- return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
2444
- }
2445
- function voyageLog(data, projectId) {
2446
- const project = data.projects.find((p) => p.id === projectId);
2447
- if (!project) return null;
2448
- const tasks = data.tasks.filter((t) => t.projectId === projectId);
2449
- const byId = new Map(data.tasks.map((t) => [t.id, t]));
2450
- const plannedStart = (project.baselineStart ?? project.startDate).slice(0, 10);
2451
- const plannedEnd = (project.baselineEnd ?? project.endDate).slice(0, 10);
2452
- const finished = finishedOn(project, tasks);
2453
- const half = Math.floor(between(plannedStart, plannedEnd) / 2);
2454
- const addedLate = tasks.filter((t) => between(plannedStart, t.createdAt) > half).length;
2455
- const slips = tasks.filter((t) => t.status === "done" && t.dueDate && t.completedAt).map((t) => {
2456
- return {
2457
- task: t,
2458
- daysLate: between(t.dueDate, t.completedAt),
2459
- waitedOn: blockerIds(t).map((id) => byId.get(id)).filter((b) => Boolean(b?.completedAt && b.completedAt.slice(0, 10) > t.dueDate.slice(0, 10))).sort((a, b) => b.completedAt.localeCompare(a.completedAt))[0] ?? null
2460
- };
2461
- }).filter((s) => s.daysLate > 0).sort((a, b) => b.daysLate - a.daysLate).slice(0, 3);
2462
- const raid = data.raidItems.filter((r) => r.projectId === projectId);
2463
- const issues = raid.filter((r) => r.kind === "issue");
2464
- const risks = raid.filter((r) => r.kind === "risk").map((risk) => {
2465
- if (typeof risk.happened === "boolean") return {
2466
- risk,
2467
- happened: risk.happened,
2468
- guessed: false
2469
- };
2470
- const said = `${risk.title} ${risk.description}`;
2471
- return {
2472
- risk,
2473
- happened: issues.some((i) => looksAlike(said, `${i.title} ${i.description}`)),
2474
- guessed: true
2475
- };
2476
- });
2477
- const decisions = raid.filter((r) => r.kind === "decision" && r.status !== "proposed").sort((a, b) => (a.decidedAt ?? a.createdAt).localeCompare(b.decidedAt ?? b.createdAt));
2478
- const counts = /* @__PURE__ */ new Map();
2479
- for (const t of tasks) if (t.status === "done" && t.assigneeId) counts.set(t.assigneeId, (counts.get(t.assigneeId) ?? 0) + 1);
2480
- const people = new Map(data.people.map((p) => [p.id, p]));
2481
- const carriers = [...counts].map(([id, closed]) => ({
2482
- person: people.get(id),
2483
- closed
2484
- })).filter((c) => c.person).sort((a, b) => b.closed - a.closed || a.person.name.localeCompare(b.person.name));
2485
- const lines = (data.reflections ?? []).filter((r) => r.projectId === projectId && r.kind === "voyage").sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2486
- return {
2487
- project,
2488
- done: project.health === "done",
2489
- plannedStart,
2490
- plannedEnd,
2491
- finished,
2492
- daysOver: between(plannedEnd, finished),
2493
- fromBaseline: Boolean(project.baselineEnd),
2494
- tasks: tasks.length,
2495
- addedLate,
2496
- slips,
2497
- risks,
2498
- issues: issues.length,
2499
- charter: charterCheck(project.charter, tasks),
2500
- decisions,
2501
- carriers,
2502
- budget: project.budget,
2503
- spent: project.spent,
2504
- lines
2505
- };
2421
+ //#region src/lib/countries.ts
2422
+ /** A country code Harbor has holidays for, upper-cased; null otherwise. */
2423
+ function countryCode(text) {
2424
+ if (!text) return null;
2425
+ const t = text.trim();
2426
+ const up = t.toUpperCase();
2427
+ if (KNOWN.has(up)) return up;
2428
+ if (up === "UK") return "GB";
2429
+ if (up === "USA") return "US";
2430
+ return COUNTRIES.find((c) => c.name.toLowerCase() === t.toLowerCase())?.code ?? null;
2506
2431
  }
2507
- const plural$4 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
2508
- const md$2 = (iso) => format(day$6(iso), "MMM d");
2509
- /** The three numbers at the top: how late, how much work, how many risks came true. */
2510
- function voyageStats(log) {
2511
- const happened = log.risks.filter((r) => r.happened).length;
2512
- const plan = log.fromBaseline ? "baseline" : "plan";
2513
- const days = log.daysOver;
2514
- return [
2515
- !log.done ? {
2516
- value: md$2(log.plannedEnd),
2517
- label: "planned end; not Done yet"
2518
- } : days === 0 ? {
2519
- value: "On time",
2520
- label: "on the planned day"
2521
- } : days > 0 ? {
2522
- value: `+${plural$4(days, "day")}`,
2523
- label: `past the ${plan}`
2524
- } : {
2525
- value: plural$4(-days, "day"),
2526
- label: `ahead of the ${plan}`
2432
+ var COUNTRIES, KNOWN, countryName;
2433
+ var init_countries = __esmMin((() => {
2434
+ COUNTRIES = [
2435
+ {
2436
+ code: "AU",
2437
+ name: "Australia"
2527
2438
  },
2528
2439
  {
2529
- value: String(log.tasks),
2530
- label: log.addedLate ? `tasks, ${log.addedLate} added late` : log.tasks === 1 ? "task" : "tasks"
2440
+ code: "BR",
2441
+ name: "Brazil"
2531
2442
  },
2532
2443
  {
2533
- value: log.risks.length ? `${happened} of ${log.risks.length}` : "0",
2534
- label: log.risks.length ? "risks came true" : "risks were logged"
2444
+ code: "CA",
2445
+ name: "Canada"
2446
+ },
2447
+ {
2448
+ code: "FR",
2449
+ name: "France"
2450
+ },
2451
+ {
2452
+ code: "DE",
2453
+ name: "Germany"
2454
+ },
2455
+ {
2456
+ code: "IE",
2457
+ name: "Ireland"
2458
+ },
2459
+ {
2460
+ code: "IT",
2461
+ name: "Italy"
2462
+ },
2463
+ {
2464
+ code: "MX",
2465
+ name: "Mexico"
2466
+ },
2467
+ {
2468
+ code: "NL",
2469
+ name: "Netherlands"
2470
+ },
2471
+ {
2472
+ code: "NZ",
2473
+ name: "New Zealand"
2474
+ },
2475
+ {
2476
+ code: "ES",
2477
+ name: "Spain"
2478
+ },
2479
+ {
2480
+ code: "GB",
2481
+ name: "United Kingdom"
2482
+ },
2483
+ {
2484
+ code: "US",
2485
+ name: "United States"
2535
2486
  }
2536
2487
  ];
2488
+ KNOWN = new Set(COUNTRIES.map((c) => c.code));
2489
+ countryName = (code) => COUNTRIES.find((c) => c.code === code)?.name ?? code;
2490
+ }));
2491
+
2492
+ //#endregion
2493
+ //#region src/lib/holidays.ts
2494
+ /** Easter Sunday (the anonymous Gregorian algorithm). */
2495
+ function easter(year) {
2496
+ const a = year % 19;
2497
+ const b = Math.floor(year / 100);
2498
+ const c = year % 100;
2499
+ const d = Math.floor(b / 4);
2500
+ const e = b % 4;
2501
+ const f = Math.floor((b + 8) / 25);
2502
+ const g = Math.floor((b - f + 1) / 3);
2503
+ const h = (19 * a + b - d - g + 15) % 30;
2504
+ const i = Math.floor(c / 4);
2505
+ const k = c % 4;
2506
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
2507
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
2508
+ const month = Math.floor((h + l - 7 * m + 114) / 31);
2509
+ const day = (h + l - 7 * m + 114) % 31 + 1;
2510
+ return iso$4(year, month, day);
2537
2511
  }
2538
- /** What happened, a sentence each: the slips and their causes, the risks that came true, who carried the most, the money. */
2539
- function voyageStory(log) {
2540
- const out = [];
2541
- const planned = `Planned ${md$2(log.plannedStart)} to ${md$2(log.plannedEnd)}`;
2542
- out.push(log.done ? `${planned}; finished ${md$2(log.finished)}.` : `${planned}; not Done yet.`);
2543
- for (const s of log.slips) {
2544
- const late = `“${s.task.title}” finished ${plural$4(s.daysLate, "day")} late`;
2545
- out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md$2(s.waitedOn.completedAt)}.` : `${late}.`);
2546
- }
2547
- for (const r of log.risks.filter((x) => x.happened)) out.push(`The risk “${r.risk.title}” came true${r.guessed ? " (an issue names it)" : ""}.`);
2548
- const against = charterSentence(log.charter);
2549
- if (against) out.push(against);
2550
- if (log.decisions.length) {
2551
- const changed = log.decisions.filter((d) => d.status === "superseded").length;
2552
- out.push(`${plural$4(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
2512
+ /** The nth given weekday (0 Sunday) of a month; n = -1 for the last. */
2513
+ function nth(year, month, dow, n) {
2514
+ if (n > 0) {
2515
+ const first = iso$4(year, month, 1);
2516
+ const shift = (dow - weekday(first) + 7) % 7;
2517
+ return plus$1(first, shift + (n - 1) * 7);
2553
2518
  }
2554
- const top = log.carriers[0];
2555
- if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural$4(top.closed, "task")}, the most on the crew.`);
2556
- else if (top) out.push(`${top.person.name} closed ${plural$4(top.closed, "task")}.`);
2557
- if (log.budget > 0) {
2558
- const pct = Math.round(log.spent / log.budget * 100);
2559
- out.push(`Spent ${pct}% of the budget.`);
2519
+ const last = iso$4(year, month + 1, 0);
2520
+ return plus$1(last, -((weekday(last) - dow + 7) % 7));
2521
+ }
2522
+ /** A country's public holidays in a year, by the day they're taken, oldest first. */
2523
+ function holidaysIn(country, year) {
2524
+ const key = `${country}|${year}`;
2525
+ const had = cache.get(key);
2526
+ if (had) return had;
2527
+ const days = (RULES[country] ?? []).map(([name, day, move = "none"]) => ({
2528
+ name,
2529
+ date: day(year),
2530
+ move
2531
+ })).filter((h) => h.date);
2532
+ const taken = new Set(days.filter((h) => !isWeekend(h.date)).map((h) => h.date));
2533
+ const out = [];
2534
+ for (const h of days) {
2535
+ let date = h.date;
2536
+ const wd = weekday(date);
2537
+ if (h.move === "us" && (wd === 0 || wd === 6)) date = plus$1(date, wd === 6 ? -1 : 1);
2538
+ else if (h.move === "next" && (wd === 0 || wd === 6)) {
2539
+ date = plus$1(date, 1);
2540
+ while (taken.has(date) || isWeekend(date)) date = plus$1(date, 1);
2541
+ taken.add(date);
2542
+ }
2543
+ out.push({
2544
+ date,
2545
+ name: h.name,
2546
+ country
2547
+ });
2560
2548
  }
2549
+ out.sort((a, b) => a.date.localeCompare(b.date));
2550
+ cache.set(key, out);
2561
2551
  return out;
2562
2552
  }
2563
-
2564
- //#endregion
2565
- //#region src/lib/dead-reckoning.ts
2566
- init_date_fns();
2567
- init_date_text();
2568
- init_format();
2569
- init_premortem();
2570
- const DAY = (iso) => parseIso(iso.slice(0, 10));
2571
- const span$1 = (a, b) => differenceInCalendarDays(DAY(b), DAY(a));
2572
- /** Projects marked Done that have tasks to copy, most recently finished first. */
2573
- function finishedProjects(data) {
2574
- return data.projects.filter((p) => p.health === "done" && tasksOf(data.tasks, p.id).length > 0).map((p) => ({
2575
- p,
2576
- at: finishedOn(p, tasksOf(data.tasks, p.id))
2577
- })).sort((a, b) => b.at.localeCompare(a.at)).map((x) => x.p);
2578
- }
2579
- /**
2580
- * The finished projects a new one sounds like, best first: those sharing a
2581
- * meaningful word of its name or description with their name or
2582
- * description, the same program counting as one more.
2583
- */
2584
- function looksLike(data, name, opts = {}) {
2585
- const want = contentWords(`${name} ${opts.description ?? ""}`);
2586
- if (want.size === 0) return [];
2553
+ /** Holidays in the given countries between two days, both included. */
2554
+ function holidaysBetween(countries, from, to) {
2587
2555
  const out = [];
2588
- for (const project of finishedProjects(data)) {
2589
- const theirs = contentWords(`${project.name} ${project.description}`);
2590
- let score = 0;
2591
- for (const w of want) if (theirs.has(w)) score += 1;
2592
- if (score === 0) continue;
2593
- if (opts.programId && project.programId === opts.programId) score += 1;
2594
- out.push({
2595
- project,
2596
- score
2597
- });
2598
- }
2599
- return out.sort((a, b) => b.score - a.score);
2556
+ const y0 = Number(from.slice(0, 4));
2557
+ const y1 = Number(to.slice(0, 4));
2558
+ for (const c of countries) for (let y = y0; y <= y1; y++) for (const h of holidaysIn(c, y)) if (h.date >= from && h.date <= to) out.push(h);
2559
+ return out.sort((a, b) => a.date.localeCompare(b.date) || a.country.localeCompare(b.country));
2600
2560
  }
2601
- /** The finished project's shape as it actually ran, ready to copy. */
2602
- function reckon(data, fromId) {
2603
- const log = voyageLog(data, fromId);
2604
- if (!log) return null;
2605
- const from = log.project;
2606
- const start = log.plannedStart;
2607
- const took = Math.max(1, span$1(start, log.finished));
2608
- const planned = Math.max(1, span$1(start, log.plannedEnd));
2609
- const half = Math.floor(planned / 2);
2610
- const tasks = [];
2611
- const slips = [];
2612
- const own = [...tasksOf(data.tasks, fromId)].sort((a, b) => (a.number ?? 0) - (b.number ?? 0) || a.createdAt.localeCompare(b.createdAt));
2613
- for (const t of own) {
2614
- const actual = t.completedAt ? span$1(start, t.completedAt) : took;
2615
- const dueIn = Math.min(took, Math.max(0, actual));
2616
- tasks.push({
2617
- title: t.title,
2618
- description: t.description,
2619
- priority: t.priority,
2620
- dueIn,
2621
- addedLate: span$1(start, t.createdAt) > half
2622
- });
2623
- if (t.dueDate && t.completedAt) {
2624
- const plannedIn = span$1(start, t.dueDate);
2625
- if (dueIn > plannedIn) slips.push({
2626
- title: t.title,
2627
- planned: Math.max(0, plannedIn),
2628
- took: dueIn
2629
- });
2561
+ /** Days someone is away: from the handover day up to the day before they're back. */
2562
+ function awayDays(person) {
2563
+ const a = person.away;
2564
+ if (!a?.until || !a.since) return null;
2565
+ return [a.since.slice(0, 10), plus$1(a.until, -1)];
2566
+ }
2567
+ /** The harbor's calendar of who works when; see the top of this file. */
2568
+ function workCalendar(data) {
2569
+ const harbor = (data.workCalendar?.countries ?? []).filter((c) => countryCode(c) === c);
2570
+ const people = new Map(data.people.map((p) => [p.id, p]));
2571
+ const own = (id) => {
2572
+ const c = id ? countryCode(people.get(id)?.country) : null;
2573
+ return c ? [c] : harbor;
2574
+ };
2575
+ const byDay = /* @__PURE__ */ new Map();
2576
+ const holidayOn = (countries, day) => {
2577
+ const year = Number(day.slice(0, 4));
2578
+ for (const c of countries) {
2579
+ const k = `${c}|${year}`;
2580
+ let days = byDay.get(k);
2581
+ if (!days) {
2582
+ days = new Map(holidaysIn(c, year).map((h) => [h.date, h]));
2583
+ byDay.set(k, days);
2584
+ }
2585
+ const h = days.get(day);
2586
+ if (h) return h;
2630
2587
  }
2631
- }
2632
- slips.sort((a, b) => b.took - b.planned - (a.took - a.planned));
2588
+ return null;
2589
+ };
2590
+ const offOn = (personId, day) => {
2591
+ if (isWeekend(day)) return null;
2592
+ const person = personId ? people.get(personId) : void 0;
2593
+ const away = person ? awayDays(person) : null;
2594
+ if (away && day >= away[0] && day <= away[1]) return { kind: "away" };
2595
+ const h = holidayOn(own(personId), day);
2596
+ return h ? {
2597
+ kind: "holiday",
2598
+ name: h.name,
2599
+ country: h.country
2600
+ } : null;
2601
+ };
2633
2602
  return {
2634
- from,
2635
- planned,
2636
- took,
2637
- tasks,
2638
- risks: log.risks.filter((r) => r.happened).map(({ risk }) => ({
2639
- title: risk.title,
2640
- description: risk.description,
2641
- probability: risk.probability ?? null,
2642
- impact: risk.impact ?? null,
2643
- mitigation: risk.mitigation ?? ""
2644
- })),
2645
- slips: slips.slice(0, 3),
2646
- addedLate: tasks.filter((t) => t.addedLate).length
2603
+ countries: harbor,
2604
+ countriesOf: own,
2605
+ offOn,
2606
+ works: (personId, day) => !isWeekend(day) && offOn(personId, day) === null,
2607
+ active: harbor.length > 0 || data.people.some((p) => countryCode(p.country) || awayDays(p) !== null)
2647
2608
  };
2648
2609
  }
2649
- /** "6 weeks", "10 days": a length the way people say it. */
2650
- function lengthText(days) {
2651
- if (days >= 14 && days % 7 <= 1) return `${Math.round(days / 7)} weeks`;
2652
- if (days >= 21) return `about ${Math.round(days / 7)} weeks`;
2653
- return `${days} day${days === 1 ? "" : "s"}`;
2654
- }
2655
- /** The one line under a match: "Planned from actuals: about 6 weeks, not the 4 Spring catalogue was planned for." */
2656
- function reckoningLine(r) {
2657
- if (r.took === r.planned) return `It ran to plan: ${lengthText(r.took)}.`;
2658
- return `Planned from what really happened: ${lengthText(r.took)}, not the ${lengthText(r.planned)} ${r.from.name} was planned for.`;
2659
- }
2660
- /** The new project's end date, as long after its start as the old one really ran. */
2661
- function reckonedEnd(r, startDate) {
2662
- return todayISO(addDays(DAY(startDate), r.took));
2663
- }
2664
- /** Puts the reckoning's tasks and risks into the new project, dated from its start. */
2665
- function applyReckoning(actions, projectId, r, startDate) {
2666
- for (const t of r.tasks) actions.addTask({
2667
- projectId,
2668
- title: t.title,
2669
- description: t.description,
2670
- status: "todo",
2671
- priority: t.priority,
2672
- assigneeId: null,
2673
- dueDate: todayISO(addDays(DAY(startDate), t.dueIn))
2674
- }, {
2675
- allowOverCap: true,
2676
- allowBlocked: true
2677
- });
2678
- for (const k of r.risks) actions.addRaidItem({
2679
- kind: "risk",
2680
- projectId,
2681
- title: k.title,
2682
- description: k.description ? `${k.description}\n\nThis came true on ${r.from.name}.` : `This came true on ${r.from.name}.`,
2683
- probability: k.probability,
2684
- impact: k.impact,
2685
- mitigation: k.mitigation
2610
+ /**
2611
+ * The public holidays anyone in the harbor takes between two days, one
2612
+ * entry a day: "Christmas Day (GB, US)". For views that aren't one person's,
2613
+ * like the timeline.
2614
+ */
2615
+ function harborHolidays(data, from, to) {
2616
+ const countries = new Set(workCalendar(data).countries);
2617
+ for (const p of data.people) {
2618
+ const c = countryCode(p.country);
2619
+ if (c) countries.add(c);
2620
+ }
2621
+ const byDay = /* @__PURE__ */ new Map();
2622
+ for (const h of holidaysBetween([...countries], from, to)) {
2623
+ if (isWeekend(h.date)) continue;
2624
+ byDay.set(h.date, [...byDay.get(h.date) ?? [], h]);
2625
+ }
2626
+ return [...byDay].map(([date, hs]) => {
2627
+ const names = [...new Set(hs.map((h) => h.name))];
2628
+ const codes = hs.map((h) => h.country).join(", ");
2629
+ return {
2630
+ date,
2631
+ names: `${names.join(" / ")} (${codes})`
2632
+ };
2686
2633
  });
2687
- return {
2688
- tasks: r.tasks.length,
2689
- risks: r.risks.length
2690
- };
2691
2634
  }
2635
+ var iso$4, weekday, plus$1, isWeekend, onOrBefore, fixed, fromEaster, MATARIKI, RULES, cache, PLAIN_CALENDAR;
2636
+ var init_holidays = __esmMin((() => {
2637
+ init_date_fns();
2638
+ init_format();
2639
+ init_countries();
2640
+ iso$4 = (y, m, d) => todayISO(new Date(y, m - 1, d, 12));
2641
+ weekday = (day) => (/* @__PURE__ */ new Date(`${day}T12:00:00`)).getDay();
2642
+ plus$1 = (day, n) => todayISO(addDays(/* @__PURE__ */ new Date(`${day}T12:00:00`), n));
2643
+ isWeekend = (day) => {
2644
+ const wd = weekday(day);
2645
+ return wd === 0 || wd === 6;
2646
+ };
2647
+ onOrBefore = (day, dow) => plus$1(day, -((weekday(day) - dow + 7) % 7));
2648
+ fixed = (m, d) => (y) => iso$4(y, m, d);
2649
+ fromEaster = (n) => (y) => plus$1(easter(y), n);
2650
+ MATARIKI = {
2651
+ 2025: "2025-06-20",
2652
+ 2026: "2026-07-10",
2653
+ 2027: "2027-06-25",
2654
+ 2028: "2028-07-14",
2655
+ 2029: "2029-07-06",
2656
+ 2030: "2030-06-21",
2657
+ 2031: "2031-07-11",
2658
+ 2032: "2032-07-02",
2659
+ 2033: "2033-06-24",
2660
+ 2034: "2034-07-07",
2661
+ 2035: "2035-06-29"
2662
+ };
2663
+ RULES = {
2664
+ US: [
2665
+ [
2666
+ "New Year's Day",
2667
+ fixed(1, 1),
2668
+ "us"
2669
+ ],
2670
+ ["Martin Luther King Jr. Day", (y) => nth(y, 1, 1, 3)],
2671
+ ["Presidents' Day", (y) => nth(y, 2, 1, 3)],
2672
+ ["Memorial Day", (y) => nth(y, 5, 1, -1)],
2673
+ [
2674
+ "Juneteenth",
2675
+ fixed(6, 19),
2676
+ "us"
2677
+ ],
2678
+ [
2679
+ "Independence Day",
2680
+ fixed(7, 4),
2681
+ "us"
2682
+ ],
2683
+ ["Labor Day", (y) => nth(y, 9, 1, 1)],
2684
+ ["Thanksgiving", (y) => nth(y, 11, 4, 4)],
2685
+ [
2686
+ "Christmas Day",
2687
+ fixed(12, 25),
2688
+ "us"
2689
+ ]
2690
+ ],
2691
+ CA: [
2692
+ [
2693
+ "New Year's Day",
2694
+ fixed(1, 1),
2695
+ "next"
2696
+ ],
2697
+ ["Good Friday", fromEaster(-2)],
2698
+ ["Victoria Day", (y) => onOrBefore(iso$4(y, 5, 24), 1)],
2699
+ [
2700
+ "Canada Day",
2701
+ fixed(7, 1),
2702
+ "next"
2703
+ ],
2704
+ ["Labour Day", (y) => nth(y, 9, 1, 1)],
2705
+ ["Thanksgiving", (y) => nth(y, 10, 1, 2)],
2706
+ [
2707
+ "Christmas Day",
2708
+ fixed(12, 25),
2709
+ "next"
2710
+ ],
2711
+ [
2712
+ "Boxing Day",
2713
+ fixed(12, 26),
2714
+ "next"
2715
+ ]
2716
+ ],
2717
+ GB: [
2718
+ [
2719
+ "New Year's Day",
2720
+ fixed(1, 1),
2721
+ "next"
2722
+ ],
2723
+ ["Good Friday", fromEaster(-2)],
2724
+ ["Easter Monday", fromEaster(1)],
2725
+ ["Early May bank holiday", (y) => nth(y, 5, 1, 1)],
2726
+ ["Spring bank holiday", (y) => nth(y, 5, 1, -1)],
2727
+ ["Summer bank holiday", (y) => nth(y, 8, 1, -1)],
2728
+ [
2729
+ "Christmas Day",
2730
+ fixed(12, 25),
2731
+ "next"
2732
+ ],
2733
+ [
2734
+ "Boxing Day",
2735
+ fixed(12, 26),
2736
+ "next"
2737
+ ]
2738
+ ],
2739
+ IE: [
2740
+ [
2741
+ "New Year's Day",
2742
+ fixed(1, 1),
2743
+ "next"
2744
+ ],
2745
+ ["St Brigid's Day", (y) => weekday(iso$4(y, 2, 1)) === 5 ? iso$4(y, 2, 1) : nth(y, 2, 1, 1)],
2746
+ [
2747
+ "St Patrick's Day",
2748
+ fixed(3, 17),
2749
+ "next"
2750
+ ],
2751
+ ["Easter Monday", fromEaster(1)],
2752
+ ["May bank holiday", (y) => nth(y, 5, 1, 1)],
2753
+ ["June bank holiday", (y) => nth(y, 6, 1, 1)],
2754
+ ["August bank holiday", (y) => nth(y, 8, 1, 1)],
2755
+ ["October bank holiday", (y) => nth(y, 10, 1, -1)],
2756
+ [
2757
+ "Christmas Day",
2758
+ fixed(12, 25),
2759
+ "next"
2760
+ ],
2761
+ [
2762
+ "St Stephen's Day",
2763
+ fixed(12, 26),
2764
+ "next"
2765
+ ]
2766
+ ],
2767
+ AU: [
2768
+ [
2769
+ "New Year's Day",
2770
+ fixed(1, 1),
2771
+ "next"
2772
+ ],
2773
+ [
2774
+ "Australia Day",
2775
+ fixed(1, 26),
2776
+ "next"
2777
+ ],
2778
+ ["Good Friday", fromEaster(-2)],
2779
+ ["Easter Monday", fromEaster(1)],
2780
+ ["Anzac Day", fixed(4, 25)],
2781
+ ["King's Birthday", (y) => nth(y, 6, 1, 2)],
2782
+ [
2783
+ "Christmas Day",
2784
+ fixed(12, 25),
2785
+ "next"
2786
+ ],
2787
+ [
2788
+ "Boxing Day",
2789
+ fixed(12, 26),
2790
+ "next"
2791
+ ]
2792
+ ],
2793
+ NZ: [
2794
+ [
2795
+ "New Year's Day",
2796
+ fixed(1, 1),
2797
+ "next"
2798
+ ],
2799
+ [
2800
+ "Day after New Year's Day",
2801
+ fixed(1, 2),
2802
+ "next"
2803
+ ],
2804
+ [
2805
+ "Waitangi Day",
2806
+ fixed(2, 6),
2807
+ "next"
2808
+ ],
2809
+ ["Good Friday", fromEaster(-2)],
2810
+ ["Easter Monday", fromEaster(1)],
2811
+ [
2812
+ "Anzac Day",
2813
+ fixed(4, 25),
2814
+ "next"
2815
+ ],
2816
+ ["King's Birthday", (y) => nth(y, 6, 1, 1)],
2817
+ ["Matariki", (y) => MATARIKI[y] ?? ""],
2818
+ ["Labour Day", (y) => nth(y, 10, 1, 4)],
2819
+ [
2820
+ "Christmas Day",
2821
+ fixed(12, 25),
2822
+ "next"
2823
+ ],
2824
+ [
2825
+ "Boxing Day",
2826
+ fixed(12, 26),
2827
+ "next"
2828
+ ]
2829
+ ],
2830
+ DE: [
2831
+ ["Neujahr", fixed(1, 1)],
2832
+ ["Karfreitag", fromEaster(-2)],
2833
+ ["Ostermontag", fromEaster(1)],
2834
+ ["Tag der Arbeit", fixed(5, 1)],
2835
+ ["Christi Himmelfahrt", fromEaster(39)],
2836
+ ["Pfingstmontag", fromEaster(50)],
2837
+ ["Tag der Deutschen Einheit", fixed(10, 3)],
2838
+ ["1. Weihnachtstag", fixed(12, 25)],
2839
+ ["2. Weihnachtstag", fixed(12, 26)]
2840
+ ],
2841
+ FR: [
2842
+ ["Jour de l'an", fixed(1, 1)],
2843
+ ["Lundi de Pâques", fromEaster(1)],
2844
+ ["Fête du Travail", fixed(5, 1)],
2845
+ ["Victoire 1945", fixed(5, 8)],
2846
+ ["Ascension", fromEaster(39)],
2847
+ ["Lundi de Pentecôte", fromEaster(50)],
2848
+ ["Fête nationale", fixed(7, 14)],
2849
+ ["Assomption", fixed(8, 15)],
2850
+ ["Toussaint", fixed(11, 1)],
2851
+ ["Armistice", fixed(11, 11)],
2852
+ ["Noël", fixed(12, 25)]
2853
+ ],
2854
+ NL: [
2855
+ ["Nieuwjaarsdag", fixed(1, 1)],
2856
+ ["Tweede paasdag", fromEaster(1)],
2857
+ ["Koningsdag", (y) => weekday(iso$4(y, 4, 27)) === 0 ? iso$4(y, 4, 26) : iso$4(y, 4, 27)],
2858
+ ["Hemelvaartsdag", fromEaster(39)],
2859
+ ["Tweede pinksterdag", fromEaster(50)],
2860
+ ["Eerste kerstdag", fixed(12, 25)],
2861
+ ["Tweede kerstdag", fixed(12, 26)]
2862
+ ],
2863
+ ES: [
2864
+ ["Año Nuevo", fixed(1, 1)],
2865
+ ["Epifanía", fixed(1, 6)],
2866
+ ["Viernes Santo", fromEaster(-2)],
2867
+ ["Fiesta del Trabajo", fixed(5, 1)],
2868
+ ["Asunción", fixed(8, 15)],
2869
+ ["Fiesta Nacional", fixed(10, 12)],
2870
+ ["Todos los Santos", fixed(11, 1)],
2871
+ ["Día de la Constitución", fixed(12, 6)],
2872
+ ["Inmaculada Concepción", fixed(12, 8)],
2873
+ ["Navidad", fixed(12, 25)]
2874
+ ],
2875
+ IT: [
2876
+ ["Capodanno", fixed(1, 1)],
2877
+ ["Epifania", fixed(1, 6)],
2878
+ ["Lunedì dell'Angelo", fromEaster(1)],
2879
+ ["Festa della Liberazione", fixed(4, 25)],
2880
+ ["Festa del Lavoro", fixed(5, 1)],
2881
+ ["Festa della Repubblica", fixed(6, 2)],
2882
+ ["Ferragosto", fixed(8, 15)],
2883
+ ["Ognissanti", fixed(11, 1)],
2884
+ ["Immacolata Concezione", fixed(12, 8)],
2885
+ ["Natale", fixed(12, 25)],
2886
+ ["Santo Stefano", fixed(12, 26)]
2887
+ ],
2888
+ MX: [
2889
+ ["Año Nuevo", fixed(1, 1)],
2890
+ ["Día de la Constitución", (y) => nth(y, 2, 1, 1)],
2891
+ ["Natalicio de Benito Juárez", (y) => nth(y, 3, 1, 3)],
2892
+ ["Día del Trabajo", fixed(5, 1)],
2893
+ ["Día de la Independencia", fixed(9, 16)],
2894
+ ["Día de la Revolución", (y) => nth(y, 11, 1, 3)],
2895
+ ["Navidad", fixed(12, 25)]
2896
+ ],
2897
+ BR: [
2898
+ ["Confraternização Universal", fixed(1, 1)],
2899
+ ["Carnaval", fromEaster(-48)],
2900
+ ["Carnaval", fromEaster(-47)],
2901
+ ["Sexta-feira Santa", fromEaster(-2)],
2902
+ ["Tiradentes", fixed(4, 21)],
2903
+ ["Dia do Trabalho", fixed(5, 1)],
2904
+ ["Corpus Christi", fromEaster(60)],
2905
+ ["Independência", fixed(9, 7)],
2906
+ ["Nossa Senhora Aparecida", fixed(10, 12)],
2907
+ ["Finados", fixed(11, 2)],
2908
+ ["Proclamação da República", fixed(11, 15)],
2909
+ ["Consciência Negra", fixed(11, 20)],
2910
+ ["Natal", fixed(12, 25)]
2911
+ ]
2912
+ };
2913
+ cache = /* @__PURE__ */ new Map();
2914
+ PLAIN_CALENDAR = workCalendar({ people: [] });
2915
+ }));
2692
2916
 
2693
2917
  //#endregion
2694
- //#region src/lib/raid.ts
2695
- /** Still needs someone's attention: not closed, resolved, decided or superseded. */
2696
- function isActive(item) {
2697
- return item.status === "open" || item.status === "watching" || item.status === "mitigated" || item.status === "proposed";
2698
- }
2699
- function clampScale(n) {
2700
- if (typeof n !== "number" || !Number.isFinite(n)) return null;
2701
- return Math.min(5, Math.max(1, Math.round(n)));
2702
- }
2703
- function impactFromSeverity(s) {
2704
- return s === "high" ? 4 : s === "medium" ? 3 : 2;
2918
+ //#region src/lib/tide.ts
2919
+ /** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
2920
+ function weeklyFinished(tasks, startDate, now, weeks = 6) {
2921
+ const today = noon$3(todayISO(now));
2922
+ const age = Math.floor(differenceInCalendarDays(today, noon$3(startDate)) / 7) + 1;
2923
+ const span = Math.max(1, Math.min(weeks, age));
2924
+ const counts = new Array(span).fill(0);
2925
+ for (const t of tasks) {
2926
+ if (t.status !== "done") continue;
2927
+ const at = t.completedAt ? noon$3(t.completedAt) : today;
2928
+ const back = Math.max(0, differenceInCalendarDays(today, at));
2929
+ const week = Math.floor(back / 7);
2930
+ if (week < span) counts[span - 1 - week] += 1;
2931
+ }
2932
+ return counts;
2705
2933
  }
2706
- function severityFromImpact(impact) {
2707
- if (impact == null) return "medium";
2708
- return impact >= 4 ? "high" : impact === 3 ? "medium" : "low";
2934
+ function hash(text) {
2935
+ let h = 2166136261;
2936
+ for (let i = 0; i < text.length; i++) {
2937
+ h ^= text.charCodeAt(i);
2938
+ h = Math.imul(h, 16777619);
2939
+ }
2940
+ return h >>> 0;
2709
2941
  }
2710
- /** Risk: probability × impact (1–25). Issues and decisions have no score. */
2711
- function scoreOf(item) {
2712
- if (item.kind !== "risk" || item.probability == null || item.impact == null) return null;
2713
- return item.probability * item.impact;
2942
+ /** mulberry32: small, fast, and the same everywhere for the same seed. */
2943
+ function random(seed) {
2944
+ let a = seed;
2945
+ return () => {
2946
+ a = a + 1831565813 | 0;
2947
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
2948
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
2949
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
2950
+ };
2714
2951
  }
2715
- function scoreBand(score) {
2716
- return score >= 12 ? "high" : score >= 6 ? "medium" : "low";
2952
+ /** Days from today to finishing, one per run; Infinity for a run that never does. */
2953
+ function simulate(remaining, weekly, seed, pace = 1, shares = null) {
2954
+ const next = random(seed);
2955
+ const out = [];
2956
+ for (let run = 0; run < TRIALS; run++) {
2957
+ let left = remaining;
2958
+ let days = Infinity;
2959
+ for (let week = 0; week < MAX_WEEKS; week++) {
2960
+ const drawn = weekly[Math.floor(next() * weekly.length)];
2961
+ let closed = pace === 1 ? drawn : Math.floor(drawn * pace) + (next() < drawn * pace % 1 ? 1 : 0);
2962
+ const share = shares ? shares(week) : 1;
2963
+ if (share < 1 && closed > 0) {
2964
+ const scaled = closed * share;
2965
+ closed = Math.floor(scaled) + (next() < scaled - Math.floor(scaled) ? 1 : 0);
2966
+ }
2967
+ if (closed >= left) {
2968
+ const landed = Array.from({ length: closed }, () => 1 + Math.floor(next() * 7));
2969
+ landed.sort((a, b) => a - b);
2970
+ days = week * 7 + landed[left - 1];
2971
+ break;
2972
+ }
2973
+ left -= closed;
2974
+ }
2975
+ out.push(days);
2976
+ }
2977
+ return out.sort((a, b) => a - b);
2717
2978
  }
2718
- /** Fill defaults and keep severity, impact, status and kind consistent. Safe on old rows and on fresh input. */
2719
- function normalizeRaidItem(r) {
2720
- const kind = r.kind === "issue" || r.kind === "decision" ? r.kind : "risk";
2721
- const now = (/* @__PURE__ */ new Date()).toISOString();
2722
- const impact = kind === "decision" ? null : clampScale(r.impact) ?? (r.impact === void 0 && r.severity ? impactFromSeverity(r.severity) : null);
2723
- const probability = kind === "risk" ? clampScale(r.probability) : null;
2724
- const allowed = STATUSES$1[kind];
2725
- const status = r.status && allowed.includes(r.status) ? r.status : allowed[0];
2979
+ /**
2980
+ * Per run, the fewest days the sounded tasks allow: each draws one of its
2981
+ * answers at random, so a wide spread widens the forecast. A task already
2982
+ * in play counts half its days; it's partly done.
2983
+ */
2984
+ function soundingFloors(tasks, reflections, base, seed, calendar) {
2985
+ const draws = [];
2986
+ for (const t of tasks) {
2987
+ if (t.status === "done") continue;
2988
+ const days = soundingDraws(t, reflections);
2989
+ if (days.length === 0) continue;
2990
+ draws.push({
2991
+ days,
2992
+ share: t.status === "doing" || t.status === "review" ? .5 : 1,
2993
+ who: t.assigneeId
2994
+ });
2995
+ }
2996
+ if (draws.length === 0) return {
2997
+ floors: null,
2998
+ sounded: 0
2999
+ };
3000
+ const next = random(seed);
3001
+ const memo = /* @__PURE__ */ new Map();
3002
+ const ahead = (d, who) => {
3003
+ const k = `${who}|${d}`;
3004
+ let v = memo.get(k);
3005
+ if (v === void 0) {
3006
+ const works = calendar?.active ? (at) => calendar.works(who, todayISO(at)) : void 0;
3007
+ memo.set(k, v = workingDaysAhead(d, base, works));
3008
+ }
3009
+ return v;
3010
+ };
3011
+ const floors = [];
3012
+ for (let run = 0; run < TRIALS; run++) {
3013
+ let most = 0;
3014
+ for (const x of draws) {
3015
+ const d = x.days[Math.floor(next() * x.days.length)] * x.share;
3016
+ most = Math.max(most, ahead(d, x.who));
3017
+ }
3018
+ floors.push(most);
3019
+ }
2726
3020
  return {
2727
- id: r.id,
2728
- kind,
2729
- portfolioId: r.portfolioId ?? null,
2730
- programId: r.programId ?? null,
2731
- projectId: r.projectId ?? null,
2732
- title: r.title ?? "",
2733
- description: r.description ?? "",
2734
- severity: kind === "decision" ? "low" : severityFromImpact(impact),
2735
- status,
2736
- ownerId: r.ownerId ?? null,
2737
- dueDate: r.dueDate ?? null,
2738
- probability,
2739
- impact,
2740
- mitigation: r.mitigation ?? "",
2741
- contingency: r.contingency ?? "",
2742
- resolution: r.resolution ?? "",
2743
- rationale: r.rationale ?? "",
2744
- decidedById: r.decidedById ?? null,
2745
- decidedAt: r.decidedAt ?? null,
2746
- notes: r.notes ?? "",
2747
- createdById: r.createdById ?? null,
2748
- createdAt: r.createdAt ?? now,
2749
- updatedAt: r.updatedAt ?? r.createdAt ?? now,
2750
- updates: Array.isArray(r.updates) ? r.updates : [],
2751
- scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
2752
- ...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {},
2753
- ...kind === "decision" ? decisionFields$1(r) : {},
2754
- ...ids(r.followerIds).length ? { followerIds: ids(r.followerIds) } : {}
3021
+ floors,
3022
+ sounded: draws.length
2755
3023
  };
2756
3024
  }
2757
- /** A revisit condition that still says something, or null. */
2758
- function cleanRevisit(v) {
2759
- if (!v || typeof v !== "object") return null;
2760
- const r = v;
2761
- const str = (x) => typeof x === "string" && x ? x : null;
2762
- if (r.kind === "date" && str(r.on)) return {
2763
- kind: "date",
2764
- on: str(r.on).slice(0, 10)
3025
+ /**
3026
+ * Each coming week's share of the crew's usual working days, from day 1
3027
+ * (tomorrow): who owns the open tasks, less their holidays and time away.
3028
+ * Null when the calendar has nothing in it, so the forecast reads as before.
3029
+ */
3030
+ function weekShares(tasks, base, calendar) {
3031
+ const crew = [...new Set(tasks.filter((t) => t.status !== "done").map((t) => t.assigneeId))].filter((id) => id !== null);
3032
+ const people = crew.length ? crew : [null];
3033
+ if (!calendar?.active) return {
3034
+ shares: null,
3035
+ crew: people
2765
3036
  };
2766
- if (r.kind === "risk" && str(r.riskId)) return {
2767
- kind: "risk",
2768
- riskId: str(r.riskId)
3037
+ const known = [];
3038
+ const share = (week) => {
3039
+ const had = known[week];
3040
+ if (had !== void 0) return had;
3041
+ let usual = 0;
3042
+ let working = 0;
3043
+ for (let d = week * 7 + 1; d <= week * 7 + 7; d++) {
3044
+ const day = todayISO(addDays(base, d));
3045
+ const wd = addDays(base, d).getDay();
3046
+ if (wd === 0 || wd === 6) continue;
3047
+ for (const p of people) {
3048
+ usual++;
3049
+ if (calendar.works(p, day)) working++;
3050
+ }
3051
+ }
3052
+ return known[week] = usual ? working / usual : 1;
2769
3053
  };
2770
- if (r.kind === "milestone" && str(r.milestoneId) && str(r.due)) return {
2771
- kind: "milestone",
2772
- milestoneId: str(r.milestoneId),
2773
- due: str(r.due).slice(0, 10)
3054
+ return {
3055
+ shares: share,
3056
+ crew: people
2774
3057
  };
2775
- return null;
2776
3058
  }
2777
- /** A decision's own fields, kept only where they say something so older rows stay as they were. */
2778
- function decisionFields$1(r) {
2779
- const out = {};
2780
- if (r.turnedDown?.trim()) out.turnedDown = r.turnedDown;
2781
- const tasks = ids(r.taskIds);
2782
- if (tasks.length) out.taskIds = tasks;
2783
- const risks = ids(r.riskIds);
2784
- if (risks.length) out.riskIds = risks;
2785
- const revisit = cleanRevisit(r.revisitIf);
2786
- if (revisit) out.revisitIf = revisit;
2787
- if (r.supersedesId) out.supersedesId = r.supersedesId;
2788
- return out;
3059
+ /** The holidays and away days the crew has between tomorrow and `until`. */
3060
+ function ashoreUntil(calendar, crew, base, until) {
3061
+ const holidays = [];
3062
+ let awayDays = 0;
3063
+ if (!calendar?.active || !Number.isFinite(until)) return {
3064
+ holidays,
3065
+ awayDays
3066
+ };
3067
+ const seen = /* @__PURE__ */ new Set();
3068
+ for (let d = 1; d <= until; d++) {
3069
+ const day = todayISO(addDays(base, d));
3070
+ for (const p of crew) {
3071
+ const off = calendar.offOn(p, day);
3072
+ if (off?.kind === "away") awayDays++;
3073
+ else if (off?.kind === "holiday" && !seen.has(`${day}|${off.country}`)) {
3074
+ seen.add(`${day}|${off.country}`);
3075
+ holidays.push({
3076
+ date: day,
3077
+ name: off.name,
3078
+ country: off.country
3079
+ });
3080
+ }
3081
+ }
3082
+ }
3083
+ return {
3084
+ holidays,
3085
+ awayDays
3086
+ };
2789
3087
  }
2790
- /** Active first, then by score, then most recently touched. */
2791
- function sortRaid(items) {
2792
- return [...items].sort((a, b) => {
2793
- const act = Number(isActive(b)) - Number(isActive(a));
2794
- if (act) return act;
2795
- const sa = scoreOf(a) ?? (a.impact ?? 0) * 2;
2796
- const sb = scoreOf(b) ?? (b.impact ?? 0) * 2;
2797
- if (sb !== sa) return sb - sa;
2798
- return b.updatedAt.localeCompare(a.updatedAt);
2799
- });
3088
+ /** What the forecast reads beyond the tasks: soundings and the work calendar. */
3089
+ function tideOptions(data) {
3090
+ return {
3091
+ reflections: data.reflections,
3092
+ calendar: workCalendar({
3093
+ people: data.people ?? [],
3094
+ workCalendar: data.workCalendar
3095
+ })
3096
+ };
2800
3097
  }
2801
3098
  /**
2802
- * Live risks rated on both scales, placed on the 5×5 grid: rows run from
2803
- * impact 5 down to 1, columns from probability 1 up to 5, so the top-right
2804
- * corner is the worst place to be.
3099
+ * `pace` scales the crew's weekly counts (1 is as they are), for dry dock's
3100
+ * "what if Sam joined" (src/lib/dry-dock.ts).
2805
3101
  */
2806
- function heatGrid(items) {
2807
- const rows = [];
2808
- for (let impact = 5; impact >= 1; impact -= 1) {
2809
- const row = [];
2810
- for (let probability = 1; probability <= 5; probability += 1) row.push({
2811
- probability,
2812
- impact,
2813
- band: scoreBand(probability * impact),
2814
- items: []
2815
- });
2816
- rows.push(row);
2817
- }
2818
- for (const item of items) {
2819
- if (item.kind !== "risk" || !isActive(item)) continue;
2820
- const p = clampScale(item.probability);
2821
- const i = clampScale(item.impact);
2822
- if (p == null || i == null) continue;
2823
- rows[5 - i][p - 1].items.push(item);
2824
- }
2825
- return rows;
2826
- }
2827
- /** True when a risk sits in the given cell of the grid. */
2828
- function inHeatCell(item, probability, impact) {
2829
- return item.kind === "risk" && item.probability === probability && item.impact === impact;
2830
- }
2831
- var KIND_LABEL, STATUS_LABEL$1, STATUSES$1, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY, ids;
2832
- var init_raid = __esmMin((() => {
2833
- KIND_LABEL = {
2834
- risk: "Risk",
2835
- issue: "Issue",
2836
- decision: "Decision"
3102
+ function tideForecast(project, tasks, now, opts = {}) {
3103
+ const remaining = tasks.filter((t) => t.status !== "done").length;
3104
+ if (remaining === 0) return { kind: "done" };
3105
+ if (project.health === "paused") return {
3106
+ kind: "paused",
3107
+ remaining
2837
3108
  };
2838
- STATUS_LABEL$1 = {
2839
- open: "Open",
2840
- watching: "Watching",
2841
- mitigated: "Mitigated",
2842
- resolved: "Resolved",
2843
- closed: "Closed",
2844
- proposed: "Proposed",
2845
- decided: "Decided",
2846
- superseded: "Superseded"
2847
- };
2848
- STATUSES$1 = {
2849
- risk: [
2850
- "open",
2851
- "watching",
2852
- "mitigated",
2853
- "closed"
2854
- ],
2855
- issue: [
2856
- "open",
2857
- "resolved",
2858
- "closed"
2859
- ],
2860
- decision: [
2861
- "proposed",
2862
- "decided",
2863
- "superseded"
2864
- ]
3109
+ const weekly = weeklyFinished(tasks, project.startDate, now);
3110
+ const finished = weekly.reduce((a, b) => a + b, 0);
3111
+ if (finished === 0) return {
3112
+ kind: "no-pace",
3113
+ remaining
2865
3114
  };
2866
- PROBABILITY_LABEL = [
2867
- "",
2868
- "Rare",
2869
- "Unlikely",
2870
- "Possible",
2871
- "Likely",
2872
- "Almost certain"
2873
- ];
2874
- IMPACT_LABEL = [
2875
- "",
2876
- "Minor",
2877
- "Small",
2878
- "Moderate",
2879
- "Major",
2880
- "Severe"
2881
- ];
2882
- FIELD_COPY = {
2883
- risk: {
2884
- description: "What could happen",
2885
- mitigation: "How we reduce it",
2886
- contingency: "If it happens",
2887
- dueDate: "Review by"
2888
- },
2889
- issue: {
2890
- description: "What's going on",
2891
- resolution: "How it gets resolved",
2892
- dueDate: "Needs an answer by"
2893
- },
2894
- decision: {
2895
- description: "Context",
2896
- resolution: "The decision",
2897
- rationale: "Why",
2898
- dueDate: "Decide by"
2899
- }
3115
+ const today = todayISO(now);
3116
+ const base = noon$3(today);
3117
+ const { shares, crew } = weekShares(tasks, base, opts.calendar);
3118
+ const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1), shares);
3119
+ const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
3120
+ let lastDue = null;
3121
+ for (const t of tasks) {
3122
+ if (t.status === "done" || !t.dueDate) continue;
3123
+ if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
3124
+ }
3125
+ const floor = lastDue ? differenceInCalendarDays(noon$3(lastDue.dueDate), base) : 0;
3126
+ const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`), opts.calendar);
3127
+ const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
3128
+ const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
3129
+ const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
3130
+ const toEnd = differenceInCalendarDays(noon$3(project.endDate), base);
3131
+ const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
3132
+ const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
3133
+ const curve = [];
3134
+ const step = Math.max(1, Math.ceil(last / 60));
3135
+ let i = 0;
3136
+ for (let d = 0; d <= last; d += step) {
3137
+ while (i < runs.length && runs[i] <= d) i++;
3138
+ curve.push({
3139
+ date: dayOf(d),
3140
+ chance: i / runs.length
3141
+ });
3142
+ }
3143
+ return {
3144
+ kind: "forecast",
3145
+ remaining,
3146
+ finished,
3147
+ weeks: weekly.length,
3148
+ perWeek: Math.round(finished / weekly.length * Math.max(0, opts.pace ?? 1) * 10) / 10,
3149
+ likely: dayOf(at(.5)),
3150
+ veryLikely: dayOf(at(.85)),
3151
+ almostCertain: dayOf(at(.95)),
3152
+ chanceByEnd: Math.round(byEnd * 100) / 100,
3153
+ curve,
3154
+ rough: finished < ROUGH_BELOW,
3155
+ heldBy,
3156
+ sounded: sounded.sounded,
3157
+ ashore: ashoreUntil(opts.calendar, crew, base, at(.85))
2900
3158
  };
2901
- ids = (v) => Array.isArray(v) ? [...new Set(v.filter((x) => typeof x === "string" && !!x))] : [];
3159
+ }
3160
+ /** "15%", with "under 5%" and "over 95%" at the ends, where a thousand runs can't be that sure. */
3161
+ function chanceText(chance) {
3162
+ if (chance < .05) return "under 5%";
3163
+ if (chance > .95) return "over 95%";
3164
+ return `${Math.round(chance * 20) * 5}%`;
3165
+ }
3166
+ /**
3167
+ * The open task whose due date sets the finish, when the crew's pace alone
3168
+ * would be done sooner; null otherwise.
3169
+ */
3170
+ function dueAfterFinish(tide) {
3171
+ return tide.kind === "forecast" ? tide.heldBy : null;
3172
+ }
3173
+ /** Why there's no forecast, for the states without one. */
3174
+ function tideQuiet(tide) {
3175
+ switch (tide.kind) {
3176
+ case "done": return "Every task is done.";
3177
+ case "paused": return "Paused, so its pace says nothing about when it ends.";
3178
+ case "no-pace": return `Nothing finished in the last ${6} weeks, so there's no pace to forecast from yet.`;
3179
+ default: return null;
3180
+ }
3181
+ }
3182
+ var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, noon$3;
3183
+ var init_tide = __esmMin((() => {
3184
+ init_date_fns();
3185
+ init_format();
3186
+ init_soundings();
3187
+ init_holidays();
3188
+ TIDE_WEEKS = 6;
3189
+ TRIALS = 1e3;
3190
+ MAX_WEEKS = 104;
3191
+ ROUGH_BELOW = 5;
3192
+ noon$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
2902
3193
  }));
2903
3194
 
2904
3195
  //#endregion
2905
- //#region src/lib/swatches.ts
2906
- var SWATCHES, HEALTH_LABEL$1, TASK_COLUMNS, TASK_STATUS_LABEL, PRIORITY_LABEL, CONSTRAINT_KIND_LABEL, CONSTRAINT_KIND_HINT;
2907
- var init_swatches = __esmMin((() => {
2908
- SWATCHES = [
2909
- "pine",
2910
- "clay",
2911
- "sage",
2912
- "sand",
2913
- "slate",
2914
- "sea",
2915
- "rust",
2916
- "moss"
2917
- ];
2918
- HEALTH_LABEL$1 = {
2919
- "on-track": "On track",
2920
- "at-risk": "At risk",
2921
- "off-track": "Off track",
2922
- paused: "Paused",
2923
- done: "Done"
2924
- };
2925
- TASK_COLUMNS = [
2926
- "backlog",
2927
- "todo",
2928
- "doing",
2929
- "review",
2930
- "done"
2931
- ];
2932
- TASK_STATUS_LABEL = {
2933
- backlog: "Backlog",
2934
- todo: "To do",
2935
- doing: "In play",
2936
- review: "Review",
2937
- done: "Done"
2938
- };
2939
- PRIORITY_LABEL = {
2940
- low: "Low",
2941
- medium: "Medium",
2942
- high: "High",
2943
- urgent: "Urgent"
2944
- };
2945
- CONSTRAINT_KIND_LABEL = {
2946
- resource: "A person",
2947
- policy: "A policy",
2948
- external: "Outside the building",
2949
- collision: "A collision"
3196
+ //#region src/lib/barnacles.ts
3197
+ /**
3198
+ * When the plan was set: when the baseline was set, else a week after the
3199
+ * project's first task was made (the first week is the plan being written).
3200
+ */
3201
+ function planSetAt(project, tasks) {
3202
+ if (project.baselineAt) return {
3203
+ since: project.baselineAt,
3204
+ from: "baseline"
3205
+ };
3206
+ let first = null;
3207
+ for (const t of tasks) if (!first || t.createdAt < first) first = t.createdAt;
3208
+ first = (first ?? project.startDate).slice(0, 10);
3209
+ return {
3210
+ since: `${isoDay$3(addDays(noon$2(first), 7))}T00:00:00`,
3211
+ from: "start"
2950
3212
  };
2951
- CONSTRAINT_KIND_HINT = {
2952
- resource: "Someone whose calendar sets the date.",
2953
- policy: "A rule that creates the wait.",
2954
- external: "A vendor, a city, a review board.",
2955
- collision: "Two things sharing one window."
3213
+ }
3214
+ /**
3215
+ * A project's barnacles from its own tasks. `slip` is the days late being
3216
+ * explained (the forecast past the planned end, the end's move from the
3217
+ * baseline, or a finished project's days over), when there is one.
3218
+ */
3219
+ function barnaclesOf(project, tasks, people, now, slip = null) {
3220
+ const { since, from } = planSetAt(project, tasks);
3221
+ const today = todayISO(now);
3222
+ const weekAgo = isoDay$3(addDays(noon$2(today), -7));
3223
+ let planned = 0;
3224
+ const added = [];
3225
+ for (const t of tasks) if (t.createdAt > since) added.push(t);
3226
+ else planned += 1;
3227
+ const first = startOfWeek(noon$2(since), { weekStartsOn: 1 });
3228
+ const span = Math.max(0, Math.floor(differenceInCalendarDays(noon$2(today), first) / 7));
3229
+ const weeks = Array.from({ length: Math.min(span + 1, 104) }, (_, i) => ({
3230
+ week: isoDay$3(addDays(first, i * 7)),
3231
+ added: 0
3232
+ }));
3233
+ const counts = /* @__PURE__ */ new Map();
3234
+ for (const t of added) {
3235
+ const i = Math.floor(differenceInCalendarDays(noon$2(t.createdAt), first) / 7);
3236
+ const slot = weeks[Math.min(Math.max(0, i), weeks.length - 1)];
3237
+ if (slot) slot.added += 1;
3238
+ const who = t.createdById ?? null;
3239
+ counts.set(who, (counts.get(who) ?? 0) + 1);
3240
+ }
3241
+ const names = new Map(people.map((p) => [p.id, p.name]));
3242
+ const adders = [...counts].map(([personId, n]) => ({
3243
+ personId: personId && names.has(personId) ? personId : null,
3244
+ name: personId && names.get(personId) || "not recorded",
3245
+ added: n
3246
+ })).sort((a, b) => (a.personId == null ? 1 : 0) - (b.personId == null ? 1 : 0) || b.added - a.added);
3247
+ const weekly = weeklyFinished(tasks, project.startDate, now);
3248
+ const finished = weekly.reduce((a, b) => a + b, 0);
3249
+ const perWeek = finished > 0 ? Math.round(finished / weekly.length * 10) / 10 : null;
3250
+ return {
3251
+ since,
3252
+ from,
3253
+ planned,
3254
+ added: added.length,
3255
+ share: planned > 0 ? added.length / planned : null,
3256
+ thisWeek: added.filter((t) => t.createdAt.slice(0, 10) > weekAgo).length,
3257
+ weeks,
3258
+ adders,
3259
+ perWeek,
3260
+ days: perWeek ? Math.round(added.length / perWeek * 7) : null,
3261
+ slip
2956
3262
  };
3263
+ }
3264
+ /**
3265
+ * Days a project is running late against its plan: the likely finish at the
3266
+ * crew's pace past the planned end (the baseline's, else the end date), or
3267
+ * how far the end date itself has moved from the baseline; null when neither.
3268
+ */
3269
+ function slipAgainstPlan(project, tasks, now, tide = tideForecast(project, tasks, now)) {
3270
+ const plannedEnd = (project.baselineEnd ?? project.endDate).slice(0, 10);
3271
+ const likely = tide.kind === "forecast" && tide.likely ? differenceInCalendarDays(noon$2(tide.likely), noon$2(plannedEnd)) : 0;
3272
+ const moved = project.baselineEnd ? differenceInCalendarDays(noon$2(project.endDate), noon$2(project.baselineEnd)) : 0;
3273
+ const slip = Math.max(likely, moved);
3274
+ return slip > 0 ? slip : null;
3275
+ }
3276
+ /** "+14 tasks since the baseline, 38% more than planned"; null when nothing was added. */
3277
+ function barnaclesHeadline(b) {
3278
+ if (b.added === 0) return null;
3279
+ const when = b.from === "baseline" ? "since the baseline" : "since its first week";
3280
+ const pct = b.share != null ? `, ${Math.round(b.share * 100)}% more than planned` : "";
3281
+ return `+${plural$5(b.added, "task")} ${when}${pct}`;
3282
+ }
3283
+ /** What the added work costs in days, and how much of the slip it explains. */
3284
+ function barnaclesDaysLine(b) {
3285
+ if (b.added === 0 || b.days == null || b.perWeek == null) return null;
3286
+ const work = `At the crew's pace (about ${b.perWeek} a week) that's about ${plural$5(b.days, "day")} of work`;
3287
+ if (b.slip == null || b.slip <= 0) return `${work}.`;
3288
+ if (b.days >= b.slip) return `${work}: enough to explain all ${plural$5(b.slip, "day")} of the slip.`;
3289
+ return `${work}: about ${b.days} of the ${plural$5(b.slip, "day")} it has slipped.`;
3290
+ }
3291
+ /** One line for the brief or a list: "+14 tasks (38%) since the baseline, 3 this week, about 65 days of work". */
3292
+ function barnaclesShort(b) {
3293
+ const pct = b.share != null ? ` (${Math.round(b.share * 100)}%)` : "";
3294
+ const when = b.from === "baseline" ? "since the baseline" : "since its first week";
3295
+ const week = b.thisWeek ? `, ${b.thisWeek} this week` : "";
3296
+ const days = b.days != null ? `, about ${plural$5(b.days, "day")} of work` : "";
3297
+ const top = b.adders.find((a) => a.personId);
3298
+ const who = top && top.added * 2 > b.added ? `, mostly from ${top.name}` : "";
3299
+ return `+${plural$5(b.added, "task")}${pct} ${when}${week}${days}${who}`;
3300
+ }
3301
+ /** Whether the brief names it: measured from a baseline, enough added, and enough of the plan. */
3302
+ function briefWorthy(b) {
3303
+ return b.from === "baseline" && b.added >= 3 && (b.share == null || b.share >= .15);
3304
+ }
3305
+ var SETTLE_DAYS, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$5;
3306
+ var init_barnacles = __esmMin((() => {
3307
+ init_date_fns();
3308
+ init_format();
3309
+ init_tide();
3310
+ SETTLE_DAYS = 7;
3311
+ BRIEF_MIN_ADDED = 3;
3312
+ BRIEF_MIN_SHARE = .15;
3313
+ noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3314
+ isoDay$3 = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
3315
+ plural$5 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
2957
3316
  }));
2958
3317
 
2959
3318
  //#endregion
2960
- //#region src/tui/create.ts
2961
- init_date_fns();
2962
- init_format();
2963
- init_indexes();
2964
- init_date_text();
2965
- init_raid();
2966
- init_swatches();
2967
- const scale = (labels) => [
2968
- 1,
2969
- 2,
2970
- 3,
2971
- 4,
2972
- 5
2973
- ].map((n) => ({
2974
- label: `${n} ${labels[n]}`,
2975
- value: n
2976
- }));
2977
- const plus$1 = (today, days) => format(addDays(parseIso(today), days), "yyyy-MM-dd");
2978
- /** The colour a new project or program takes: the first one its neighbours don't use. */
2979
- function nextSwatch(used) {
2980
- return SWATCHES.find((s) => !used.includes(s)) ?? SWATCHES[0];
3319
+ //#region src/lib/charter-lines.ts
3320
+ /** Nothing written on any of the five lines. */
3321
+ function charterEmpty(charter) {
3322
+ return !charter || CHARTER_FIELDS.every((f) => !clean(charter[f]));
2981
3323
  }
2982
3324
  /**
2983
- * "Start empty", then the finished projects the new name sounds like (as the
2984
- * website's "This looks like …"), then the other finished ones.
3325
+ * The charter after a change to some of its lines: trimmed, and null once
3326
+ * every line is empty so a cleared charter reads as never written.
2985
3327
  */
2986
- function planFromChoices(data, finished, name, programId) {
2987
- const like = looksLike(data, name, { programId }).map((l) => l.project);
2988
- const rest = finished.filter((p) => !like.includes(p));
2989
- const choice = (p, sounds) => {
2990
- const r = reckon(data, p.id);
2991
- return {
2992
- label: `${sounds ? "This looks like" : "Plan from"} ${p.name}`,
2993
- value: p.id,
2994
- hint: `done ${shortDate(finishedOn(p, tasksOf(data.tasks, p.id)))} · ${tasksOf(data.tasks, p.id).length} tasks${r ? ` · ${reckoningLine(r)}` : ""}`
2995
- };
2996
- };
2997
- return [
2998
- {
2999
- label: "Start empty",
3000
- value: null,
3001
- hint: "or copy a finished project's shape as it really ran"
3002
- },
3003
- ...like.map((p) => choice(p, true)),
3004
- ...rest.map((p) => choice(p, false))
3005
- ];
3006
- }
3007
- const CREATE_TITLE = {
3008
- portfolio: "New portfolio",
3009
- project: "New project",
3010
- program: "New program",
3011
- milestone: "New milestone",
3012
- raid: "Log a risk, issue or decision",
3013
- spend: "Log spend"
3014
- };
3015
- function stepsFor(kind, data, ctx, me) {
3016
- const people = data.people.map((p) => ({
3017
- label: p.name,
3018
- value: p.id,
3019
- hint: p.role
3020
- }));
3021
- const meFirst = Math.max(0, people.findIndex((p) => p.value === me));
3022
- const finished = kind === "project" ? finishedProjects(data) : [];
3023
- switch (kind) {
3024
- case "portfolio": return [
3025
- {
3026
- key: "name",
3027
- label: "Name",
3028
- kind: "text",
3029
- placeholder: "e.g. Outdoor gear"
3030
- },
3031
- {
3032
- key: "ownerId",
3033
- label: "Owner",
3034
- kind: "pick",
3035
- choices: people,
3036
- initial: meFirst
3037
- },
3038
- {
3039
- key: "theme",
3040
- label: "Theme",
3041
- kind: "text",
3042
- placeholder: "What it's for, in a line",
3043
- optional: true
3044
- }
3045
- ];
3046
- case "project": return [
3047
- {
3048
- key: "name",
3049
- label: "Name",
3050
- kind: "text",
3051
- placeholder: "e.g. Fall catalogue shoot"
3052
- },
3053
- ...ctx.programId ? [] : [{
3054
- key: "programId",
3055
- label: "Program",
3056
- kind: "pick",
3057
- choices: data.programs.map((p) => ({
3058
- label: p.name,
3059
- value: p.id
3060
- }))
3061
- }],
3062
- ...finished.length ? [{
3063
- key: "likeId",
3064
- label: "Plan from",
3065
- kind: "pick",
3066
- choices: [],
3067
- choicesFor: (a) => planFromChoices(data, finished, String(a.name ?? ""), ctx.programId ?? a.programId)
3068
- }] : [],
3069
- {
3070
- key: "ownerId",
3071
- label: "Lead",
3072
- kind: "pick",
3073
- choices: people,
3074
- initial: meFirst
3075
- },
3076
- {
3077
- key: "endDate",
3078
- label: "Ends",
3079
- kind: "date",
3080
- optional: true,
3081
- skipSays: "for 60 days from today",
3082
- when: (a) => !a.likeId
3083
- },
3084
- {
3085
- key: "endDate",
3086
- label: "Ends",
3087
- kind: "date",
3088
- optional: true,
3089
- skipSays: "as long as that one really ran",
3090
- when: (a) => Boolean(a.likeId)
3091
- },
3092
- {
3093
- key: "budget",
3094
- label: "Budget",
3095
- kind: "number",
3096
- optional: true
3097
- }
3098
- ];
3099
- case "program": return [
3100
- {
3101
- key: "name",
3102
- label: "Name",
3103
- kind: "text",
3104
- placeholder: "e.g. Spring range"
3105
- },
3106
- ...ctx.portfolioId ? [] : [{
3107
- key: "portfolioId",
3108
- label: "Portfolio",
3109
- kind: "pick",
3110
- choices: data.portfolios.map((p) => ({
3111
- label: p.name,
3112
- value: p.id
3113
- }))
3114
- }],
3115
- {
3116
- key: "ownerId",
3117
- label: "Lead",
3118
- kind: "pick",
3119
- choices: people,
3120
- initial: meFirst
3121
- },
3122
- {
3123
- key: "endDate",
3124
- label: "Ends",
3125
- kind: "date"
3126
- }
3127
- ];
3128
- case "milestone": return [{
3129
- key: "name",
3130
- label: "Name",
3131
- kind: "text",
3132
- placeholder: "e.g. Samples approved"
3133
- }, {
3134
- key: "dueDate",
3135
- label: "Due",
3136
- kind: "date"
3137
- }];
3138
- case "raid": return [
3139
- {
3140
- key: "kind",
3141
- label: "Kind",
3142
- kind: "pick",
3143
- choices: [
3144
- "risk",
3145
- "issue",
3146
- "decision"
3147
- ].map((k) => ({
3148
- label: KIND_LABEL[k],
3149
- value: k
3150
- }))
3151
- },
3152
- {
3153
- key: "title",
3154
- label: "Title",
3155
- kind: "text"
3156
- },
3157
- {
3158
- key: "probability",
3159
- label: "Likelihood",
3160
- kind: "pick",
3161
- choices: scale(PROBABILITY_LABEL),
3162
- initial: 2,
3163
- when: (a) => a.kind === "risk"
3164
- },
3165
- {
3166
- key: "impact",
3167
- label: "Impact",
3168
- kind: "pick",
3169
- choices: scale(IMPACT_LABEL),
3170
- initial: 2,
3171
- when: (a) => a.kind !== "decision"
3172
- },
3173
- ...ctx.projectId ? [] : [{
3174
- key: "projectId",
3175
- label: "Project",
3176
- kind: "pick",
3177
- choices: [{
3178
- label: "None in particular",
3179
- value: null
3180
- }, ...data.projects.map((p) => ({
3181
- label: p.name,
3182
- value: p.id
3183
- }))]
3184
- }],
3185
- {
3186
- key: "ownerId",
3187
- label: "Owner",
3188
- kind: "pick",
3189
- choices: people,
3190
- initial: meFirst
3191
- }
3192
- ];
3193
- case "spend": return [
3194
- {
3195
- key: "amount",
3196
- label: "Amount",
3197
- kind: "number"
3198
- },
3199
- {
3200
- key: "date",
3201
- label: "Date (Enter for today)",
3202
- kind: "date",
3203
- optional: true
3204
- },
3205
- {
3206
- key: "note",
3207
- label: "What for",
3208
- kind: "text",
3209
- optional: true
3210
- }
3211
- ];
3212
- }
3213
- }
3214
- /** Make it; returns the new thing's id (or null when there's nothing to make it in). */
3215
- function commitCreate(store, kind, v, ctx, today) {
3216
- const s = store.getState();
3217
- const str = (k) => typeof v[k] === "string" ? v[k] : "";
3218
- switch (kind) {
3219
- case "portfolio": return s.addPortfolio({
3220
- name: str("name"),
3221
- description: "",
3222
- swatch: nextSwatch(s.portfolios.map((p) => p.swatch)),
3223
- ownerId: str("ownerId"),
3224
- budget: 0,
3225
- ...str("theme") ? { theme: str("theme") } : {}
3226
- });
3227
- case "project": {
3228
- const programId = ctx.programId ?? str("programId");
3229
- const program = s.programs.find((p) => p.id === programId);
3230
- if (!program) return null;
3231
- const reckoning = str("likeId") ? reckon(s, str("likeId")) : null;
3232
- const id = s.addProject({
3233
- name: str("name"),
3234
- description: "",
3235
- swatch: nextSwatch(s.projects.filter((p) => p.programId === programId).map((p) => p.swatch)),
3236
- ownerId: str("ownerId"),
3237
- programId,
3238
- startDate: today,
3239
- endDate: str("endDate") ? clampEndToParent(str("endDate"), program.endDate, today) : reckoning ? reckonedEnd(reckoning, today) : clampEndToParent(plus$1(today, 60), program.endDate, today),
3240
- budget: typeof v.budget === "number" ? v.budget : 0
3241
- });
3242
- if (reckoning) applyReckoning(store.getState(), id, reckoning, today);
3243
- return id;
3244
- }
3245
- case "program": {
3246
- const portfolioId = ctx.portfolioId ?? str("portfolioId");
3247
- if (!s.portfolios.some((p) => p.id === portfolioId)) return null;
3248
- return s.addProgram({
3249
- name: str("name"),
3250
- description: "",
3251
- swatch: nextSwatch(s.programs.filter((p) => p.portfolioId === portfolioId).map((p) => p.swatch)),
3252
- ownerId: str("ownerId"),
3253
- portfolioId,
3254
- startDate: today,
3255
- endDate: str("endDate") || plus$1(today, 90)
3256
- });
3257
- }
3258
- case "milestone":
3259
- if (!ctx.programId) return null;
3260
- return s.addMilestone({
3261
- programId: ctx.programId,
3262
- name: str("name"),
3263
- dueDate: str("dueDate") || plus$1(today, 14)
3264
- });
3265
- case "raid": {
3266
- const projectId = ctx.projectId ?? (v.projectId || null);
3267
- const project = projectId ? s.projects.find((p) => p.id === projectId) : null;
3268
- return s.addRaidItem({
3269
- kind: v.kind,
3270
- title: str("title"),
3271
- projectId,
3272
- programId: project?.programId ?? null,
3273
- ownerId: str("ownerId") || null,
3274
- ...typeof v.probability === "number" ? { probability: v.probability } : {},
3275
- ...typeof v.impact === "number" ? { impact: v.impact } : {}
3276
- });
3277
- }
3278
- case "spend":
3279
- if (!ctx.projectId || typeof v.amount !== "number") return null;
3280
- return s.addSpend(ctx.projectId, {
3281
- amount: v.amount,
3282
- date: str("date") || today,
3283
- note: str("note")
3284
- });
3285
- }
3286
- }
3287
-
3288
- //#endregion
3289
- //#region src/lib/board.ts
3290
- /** The column's cap after "No cap" (`uncapped`) or a number is picked. */
3291
- function withWipLimit(col, limit) {
3292
- const { uncapped: _drop, ...rest } = col;
3293
- return limit == null ? {
3294
- ...rest,
3295
- wipLimit: null,
3296
- uncapped: true
3297
- } : {
3298
- ...rest,
3299
- wipLimit: limit
3300
- };
3301
- }
3302
- function defaultWipFor(status) {
3303
- if (status === "doing") return 3;
3304
- if (status === "review") return 2;
3305
- return null;
3306
- }
3307
- function col(status, patch = {}) {
3308
- return {
3309
- status,
3310
- label: TASK_STATUS_LABEL[status],
3311
- visible: true,
3312
- wipLimit: null,
3313
- ...patch
3314
- };
3315
- }
3316
- function columnsFor(preset) {
3317
- switch (preset) {
3318
- case "lean": return [
3319
- col("backlog", { visible: false }),
3320
- col("todo"),
3321
- col("doing", { wipLimit: 3 }),
3322
- col("review", {
3323
- visible: false,
3324
- wipLimit: 2
3325
- }),
3326
- col("done")
3327
- ];
3328
- case "delivery": return [
3329
- col("backlog", { visible: false }),
3330
- col("todo"),
3331
- col("doing", { wipLimit: 3 }),
3332
- col("review", { wipLimit: 2 }),
3333
- col("done")
3334
- ];
3335
- case "intake": return [
3336
- col("backlog"),
3337
- col("todo"),
3338
- col("doing", {
3339
- visible: false,
3340
- wipLimit: 3
3341
- }),
3342
- col("review", {
3343
- visible: false,
3344
- wipLimit: 2
3345
- }),
3346
- col("done")
3347
- ];
3348
- default: return [
3349
- col("backlog"),
3350
- col("todo"),
3351
- col("doing", { wipLimit: 3 }),
3352
- col("review", { wipLimit: 2 }),
3353
- col("done")
3354
- ];
3355
- }
3356
- }
3357
- function applyPreset(id) {
3358
- return {
3359
- preset: id,
3360
- columns: columnsFor(id).map((c) => ({ ...c })),
3361
- swimlane: "none",
3362
- cardFields: { ...ALL_FIELDS },
3363
- compactCards: id === "intake"
3364
- };
3365
- }
3366
- function defaultBoardConfig() {
3367
- return applyPreset("trail");
3368
- }
3369
- function clampWip(value) {
3370
- if (value == null || value === "") return null;
3371
- const n = typeof value === "number" ? value : Number(value);
3372
- if (!Number.isFinite(n) || n <= 0) return null;
3373
- return Math.min(20, Math.round(n));
3374
- }
3375
- function capOf(c) {
3376
- const n = clampWip(c.wipLimit);
3377
- if (n != null) return { wipLimit: n };
3378
- if (c.uncapped === true) return {
3379
- wipLimit: null,
3380
- uncapped: true
3381
- };
3382
- return { wipLimit: defaultWipFor(c.status) };
3383
- }
3384
- function normalizeBoardConfig(raw) {
3385
- const base = defaultBoardConfig();
3386
- if (!raw) return base;
3387
- const byStatus = /* @__PURE__ */ new Map();
3388
- for (const c of base.columns) byStatus.set(c.status, { ...c });
3389
- const order = [];
3390
- if (Array.isArray(raw.columns)) for (const c of raw.columns) {
3391
- if (!c || !TASK_COLUMNS.includes(c.status)) continue;
3392
- if (!order.includes(c.status)) order.push(c.status);
3393
- byStatus.set(c.status, {
3394
- status: c.status,
3395
- label: typeof c.label === "string" && c.label.trim() ? c.label.trim() : TASK_STATUS_LABEL[c.status],
3396
- visible: c.visible !== false,
3397
- ...capOf(c)
3398
- });
3399
- }
3400
- for (const status of TASK_COLUMNS) if (!order.includes(status)) order.push(status);
3401
- const columns = order.map((status) => byStatus.get(status));
3402
- if (!columns.some((c) => c.visible)) {
3403
- const todo = columns.find((c) => c.status === "todo") ?? columns[0];
3404
- todo.visible = true;
3405
- }
3406
- const swimlane = raw.swimlane === "assignee" || raw.swimlane === "priority" ? raw.swimlane : "none";
3407
- const cardFields = {
3408
- dueDate: raw.cardFields?.dueDate !== false,
3409
- assignee: raw.cardFields?.assignee !== false,
3410
- priority: raw.cardFields?.priority !== false
3411
- };
3412
- return {
3413
- preset: raw.preset === "lean" || raw.preset === "delivery" || raw.preset === "intake" || raw.preset === "trail" || raw.preset === "custom" ? raw.preset : "custom",
3414
- columns,
3415
- swimlane,
3416
- cardFields,
3417
- compactCards: Boolean(raw.compactCards)
3328
+ function withCharter(current, patch, by, at) {
3329
+ const next = {
3330
+ ...current ?? {},
3331
+ updatedAt: at,
3332
+ updatedById: by
3418
3333
  };
3334
+ for (const field of CHARTER_FIELDS) {
3335
+ if (!(field in patch)) continue;
3336
+ const text = (patch[field] ?? "").trim();
3337
+ if (text) next[field] = text;
3338
+ else delete next[field];
3339
+ }
3340
+ return charterEmpty(next) ? null : next;
3419
3341
  }
3420
- function columnLabel(colCfg) {
3421
- return colCfg.label.trim() || TASK_STATUS_LABEL[colCfg.status];
3342
+ var CHARTER_FIELDS, clean;
3343
+ var init_charter_lines = __esmMin((() => {
3344
+ CHARTER_FIELDS = [
3345
+ "why",
3346
+ "inScope",
3347
+ "outScope",
3348
+ "doneWhen",
3349
+ "callsBy"
3350
+ ];
3351
+ clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
3352
+ }));
3353
+
3354
+ //#endregion
3355
+ //#region src/lib/charter.ts
3356
+ init_indexes();
3357
+ init_barnacles();
3358
+ init_date_fns();
3359
+ init_date_text();
3360
+ init_premortem();
3361
+ init_charter_lines();
3362
+ const CHARTER_LABEL = {
3363
+ why: "Why",
3364
+ inScope: "In",
3365
+ outScope: "Out",
3366
+ doneWhen: "Done when",
3367
+ callsBy: "Calls made by"
3368
+ };
3369
+ /** Each line's name for agents and the CLI's --json: plain words, no "Scope". */
3370
+ const CHARTER_KEY = {
3371
+ why: "why",
3372
+ inScope: "in",
3373
+ outScope: "out",
3374
+ doneWhen: "doneWhen",
3375
+ callsBy: "callsBy"
3376
+ };
3377
+ /** The lines written so far, in order. */
3378
+ function charterLines(charter) {
3379
+ if (!charter) return [];
3380
+ return CHARTER_FIELDS.map((field) => ({
3381
+ field,
3382
+ label: CHARTER_LABEL[field],
3383
+ text: clean(charter[field])
3384
+ })).filter((l) => l.text);
3422
3385
  }
3423
- function visibleColumns(config) {
3424
- return config.columns.filter((c) => c.visible);
3386
+ function charterFilled(charter) {
3387
+ return charterLines(charter).length;
3425
3388
  }
3389
+ /** A week after the start, an empty charter is worth a nudge. */
3390
+ const CHARTER_GRACE_DAYS = 7;
3426
3391
  /**
3427
- * A board's caps by status, for agents and the terminal: every column, hidden
3428
- * ones too (a cap can be set on them, and they come back with it), null when
3429
- * uncapped. `hiddenStatuses` says which aren't on the board.
3392
+ * A project whose charter is still blank a week after it started, while
3393
+ * it's still running. Done and paused ones are left alone.
3430
3394
  */
3431
- function columnLimits(config) {
3432
- const out = {};
3433
- for (const c of config.columns) out[c.status] = c.wipLimit;
3434
- return out;
3435
- }
3436
- function hiddenStatuses(config) {
3437
- return hiddenColumns(config).map((c) => c.status);
3395
+ function charterOverdue(project, today) {
3396
+ if (project.health === "done" || project.health === "paused") return null;
3397
+ if (!charterEmpty(project.charter)) return null;
3398
+ const days = differenceInCalendarDays(parseIso(today), parseIso(project.startDate.slice(0, 10)));
3399
+ return days >= 7 ? days : null;
3438
3400
  }
3439
- function hiddenColumns(config) {
3440
- return config.columns.filter((c) => !c.visible);
3401
+ /** The items of an In or Out line: split on commas, semicolons, "and" lists and new lines. */
3402
+ function charterItems(text) {
3403
+ return (text ?? "").split(/[\n,;•]+/).map((s) => s.replace(/^\s*(?:and|or|-|\*)\s+/i, "").trim()).filter((s) => contentWords(s).size > 0);
3441
3404
  }
3442
- function boardConfigOf(data, projectId) {
3443
- return normalizeBoardConfig(data.boardConfigs?.[projectId]);
3405
+ /** True when a task's title names an item: every meaningful word of a short item, or two of a longer one. */
3406
+ function names(item, title) {
3407
+ let shared = 0;
3408
+ for (const w of item) if (title.has(w)) shared += 1;
3409
+ return item.size <= 2 ? shared === item.size : shared >= 2;
3444
3410
  }
3445
- function applyDefaultWipToAll(configs) {
3446
- const next = {};
3447
- for (const [id, raw] of Object.entries(configs ?? {})) next[id] = normalizeBoardConfig(raw);
3448
- return next;
3411
+ /**
3412
+ * Tasks whose titles name something the charter put out of scope, newest
3413
+ * first: the "I thought that was in" list. Tasks made before the charter
3414
+ * was last written are left out when `since` is given, so writing an Out
3415
+ * line about work already on the board doesn't flag it.
3416
+ */
3417
+ function outOfScope(charter, tasks, opts = {}) {
3418
+ const items = charterItems(charter?.outScope).map((item) => ({
3419
+ item,
3420
+ words: contentWords(item)
3421
+ }));
3422
+ if (items.length === 0) return [];
3423
+ const out = [];
3424
+ for (const task of tasks) {
3425
+ if (opts.since && task.createdAt < opts.since) continue;
3426
+ const title = contentWords(task.title);
3427
+ const hit = items.find((i) => names(i.words, title));
3428
+ if (hit) out.push({
3429
+ task,
3430
+ item: hit.item
3431
+ });
3432
+ }
3433
+ return out.sort((a, b) => b.task.createdAt.localeCompare(a.task.createdAt));
3449
3434
  }
3450
- function wipOverflow(col, currentCount, extra = 1) {
3451
- if (col.wipLimit == null || extra <= 0) return null;
3452
- const next = currentCount + extra;
3453
- if (next <= col.wipLimit) return null;
3435
+ /** The voyage log's look at the charter: did the In list get done, did the Out list stay out. */
3436
+ function charterCheck(charter, tasks) {
3437
+ if (charterEmpty(charter)) return null;
3438
+ const finished = tasks.filter((t) => t.status === "done").map((t) => contentWords(t.title));
3439
+ const delivered = charterItems(charter.inScope).map((item) => {
3440
+ const words = contentWords(item);
3441
+ return {
3442
+ item,
3443
+ done: finished.filter((title) => names(words, title)).length
3444
+ };
3445
+ });
3454
3446
  return {
3455
- label: columnLabel(col),
3456
- limit: col.wipLimit,
3457
- current: currentCount,
3458
- next
3447
+ doneWhen: clean(charter.doneWhen) || null,
3448
+ delivered,
3449
+ crept: outOfScope(charter, tasks),
3450
+ outItems: charterItems(charter.outScope).length
3459
3451
  };
3460
3452
  }
3461
- function wipFullMessage(overflow) {
3462
- return `${overflow.label === "In play" ? "In-play" : overflow.label} is full (${overflow.limit}). Finish one or raise the cap.`;
3453
+ /** One sentence for the voyage log's story; null when there's no charter. */
3454
+ function charterSentence(check) {
3455
+ if (!check) return null;
3456
+ const bits = [];
3457
+ if (check.delivered.length) {
3458
+ const missing = check.delivered.filter((d) => d.done === 0).map((d) => d.item);
3459
+ bits.push(missing.length === 0 ? "every item the charter put in has finished work against it" : `${check.delivered.length - missing.length} of ${check.delivered.length} items the charter put in have finished work against them (not: ${missing.join(", ")})`);
3460
+ }
3461
+ if (check.crept.length) {
3462
+ const items = [...new Set(check.crept.map((c) => c.item))];
3463
+ bits.push(`${check.crept.length} task${check.crept.length === 1 ? "" : "s"} touched what it put out (${items.join(", ")})`);
3464
+ } else if (check.outItems > 0) bits.push("nothing it put out crept in");
3465
+ if (!bits.length) return null;
3466
+ return `Against the charter: ${bits.join("; ")}.`;
3463
3467
  }
3464
- function columnWip(data, projectId, status, extra = 1) {
3465
- const col = boardConfigOf(data, projectId).columns.find((c) => c.status === status);
3466
- if (!col) return null;
3467
- const current = tasksOf(data.tasks, projectId).filter((t) => t.status === status).length;
3468
- return wipOverflow(col, current, extra);
3468
+
3469
+ //#endregion
3470
+ //#region src/lib/voyage-log.ts
3471
+ init_date_fns();
3472
+ init_blockers();
3473
+ init_date_text();
3474
+ init_premortem();
3475
+ const day$6 = (iso) => parseIso(iso.slice(0, 10));
3476
+ const between = (a, b) => differenceInCalendarDays(day$6(b), day$6(a));
3477
+ /** When the project was marked Done: its last move to Done in its health history. */
3478
+ function finishedOn(project, tasks) {
3479
+ const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
3480
+ if (marked) return marked.at.slice(0, 10);
3481
+ return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
3469
3482
  }
3470
- var BOARD_PRESETS, ALL_FIELDS, DEFAULT_DOING_WIP, DEFAULT_REVIEW_WIP, WIP_CAP_CHOICES;
3471
- var init_board = __esmMin((() => {
3472
- init_indexes();
3473
- init_swatches();
3474
- BOARD_PRESETS = [
3475
- {
3476
- id: "trail",
3477
- name: "Trail",
3478
- kicker: "The full walk",
3479
- blurb: "Backlog through done. Three in play, two in review."
3480
- },
3481
- {
3482
- id: "lean",
3483
- name: "Lean",
3484
- kicker: "Just the work",
3485
- blurb: "To do, in play, done. A cap of three in play."
3483
+ function voyageLog(data, projectId) {
3484
+ const project = data.projects.find((p) => p.id === projectId);
3485
+ if (!project) return null;
3486
+ const tasks = data.tasks.filter((t) => t.projectId === projectId);
3487
+ const byId = new Map(data.tasks.map((t) => [t.id, t]));
3488
+ const plannedStart = (project.baselineStart ?? project.startDate).slice(0, 10);
3489
+ const plannedEnd = (project.baselineEnd ?? project.endDate).slice(0, 10);
3490
+ const finished = finishedOn(project, tasks);
3491
+ const half = Math.floor(between(plannedStart, plannedEnd) / 2);
3492
+ const addedLate = tasks.filter((t) => between(plannedStart, t.createdAt) > half).length;
3493
+ const slips = tasks.filter((t) => t.status === "done" && t.dueDate && t.completedAt).map((t) => {
3494
+ return {
3495
+ task: t,
3496
+ daysLate: between(t.dueDate, t.completedAt),
3497
+ waitedOn: blockerIds(t).map((id) => byId.get(id)).filter((b) => Boolean(b?.completedAt && b.completedAt.slice(0, 10) > t.dueDate.slice(0, 10))).sort((a, b) => b.completedAt.localeCompare(a.completedAt))[0] ?? null
3498
+ };
3499
+ }).filter((s) => s.daysLate > 0).sort((a, b) => b.daysLate - a.daysLate).slice(0, 3);
3500
+ const raid = data.raidItems.filter((r) => r.projectId === projectId);
3501
+ const issues = raid.filter((r) => r.kind === "issue");
3502
+ const risks = raid.filter((r) => r.kind === "risk").map((risk) => {
3503
+ if (typeof risk.happened === "boolean") return {
3504
+ risk,
3505
+ happened: risk.happened,
3506
+ guessed: false
3507
+ };
3508
+ const said = `${risk.title} ${risk.description}`;
3509
+ return {
3510
+ risk,
3511
+ happened: issues.some((i) => looksAlike(said, `${i.title} ${i.description}`)),
3512
+ guessed: true
3513
+ };
3514
+ });
3515
+ const decisions = raid.filter((r) => r.kind === "decision" && r.status !== "proposed").sort((a, b) => (a.decidedAt ?? a.createdAt).localeCompare(b.decidedAt ?? b.createdAt));
3516
+ const counts = /* @__PURE__ */ new Map();
3517
+ for (const t of tasks) if (t.status === "done" && t.assigneeId) counts.set(t.assigneeId, (counts.get(t.assigneeId) ?? 0) + 1);
3518
+ const people = new Map(data.people.map((p) => [p.id, p]));
3519
+ const carriers = [...counts].map(([id, closed]) => ({
3520
+ person: people.get(id),
3521
+ closed
3522
+ })).filter((c) => c.person).sort((a, b) => b.closed - a.closed || a.person.name.localeCompare(b.person.name));
3523
+ const lines = (data.reflections ?? []).filter((r) => r.projectId === projectId && r.kind === "voyage").sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3524
+ return {
3525
+ project,
3526
+ done: project.health === "done",
3527
+ plannedStart,
3528
+ plannedEnd,
3529
+ finished,
3530
+ daysOver: between(plannedEnd, finished),
3531
+ fromBaseline: Boolean(project.baselineEnd),
3532
+ tasks: tasks.length,
3533
+ addedLate,
3534
+ barnacles: barnaclesOf(project, tasks, data.people, /* @__PURE__ */ new Date(`${finished}T12:00:00`), Math.max(0, between(plannedEnd, finished)) || null),
3535
+ slips,
3536
+ risks,
3537
+ issues: issues.length,
3538
+ charter: charterCheck(project.charter, tasks),
3539
+ decisions,
3540
+ carriers,
3541
+ budget: project.budget,
3542
+ spent: project.spent,
3543
+ lines
3544
+ };
3545
+ }
3546
+ const plural$4 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3547
+ const md$2 = (iso) => format(day$6(iso), "MMM d");
3548
+ /** The three numbers at the top: how late, how much work, how many risks came true. */
3549
+ function voyageStats(log) {
3550
+ const happened = log.risks.filter((r) => r.happened).length;
3551
+ const plan = log.fromBaseline ? "baseline" : "plan";
3552
+ const days = log.daysOver;
3553
+ return [
3554
+ !log.done ? {
3555
+ value: md$2(log.plannedEnd),
3556
+ label: "planned end; not Done yet"
3557
+ } : days === 0 ? {
3558
+ value: "On time",
3559
+ label: "on the planned day"
3560
+ } : days > 0 ? {
3561
+ value: `+${plural$4(days, "day")}`,
3562
+ label: `past the ${plan}`
3563
+ } : {
3564
+ value: plural$4(-days, "day"),
3565
+ label: `ahead of the ${plan}`
3486
3566
  },
3487
3567
  {
3488
- id: "delivery",
3489
- name: "Delivery",
3490
- kicker: "A review gate",
3491
- blurb: "In-play and review are capped so work actually finishes."
3568
+ value: String(log.tasks),
3569
+ label: log.addedLate ? `tasks, ${log.addedLate} added late` : log.tasks === 1 ? "task" : "tasks"
3492
3570
  },
3493
3571
  {
3494
- id: "intake",
3495
- name: "Intake",
3496
- kicker: "Collect, then ship",
3497
- blurb: "Backlog, to do, done. For paused or sourcing work."
3572
+ value: log.risks.length ? `${happened} of ${log.risks.length}` : "0",
3573
+ label: log.risks.length ? "risks came true" : "risks were logged"
3498
3574
  }
3499
3575
  ];
3500
- ALL_FIELDS = {
3501
- dueDate: true,
3502
- assignee: true,
3503
- priority: true
3504
- };
3505
- DEFAULT_DOING_WIP = 3;
3506
- DEFAULT_REVIEW_WIP = 2;
3507
- WIP_CAP_CHOICES = [
3508
- 1,
3509
- 2,
3510
- 3,
3511
- 4,
3512
- 5,
3513
- 8,
3514
- 10,
3515
- 15
3516
- ];
3517
- }));
3518
-
3519
- //#endregion
3520
- //#region src/lib/keep.ts
3521
- init_board();
3522
- /** Whole days left in the trial, rounded up: 30 on the first day, 1 on the last. 0 once it's over. */
3523
- function trialDaysLeft(trialEndsAt, now = /* @__PURE__ */ new Date()) {
3524
- if (!trialEndsAt) return 0;
3525
- const ms = Date.parse(trialEndsAt) - now.getTime();
3526
- if (!Number.isFinite(ms) || ms <= 0) return 0;
3527
- return Math.ceil(ms / 864e5);
3528
- }
3529
-
3530
- //#endregion
3531
- //#region src/lib/cascade.ts
3532
- /** What deleting a portfolio, program, or project takes with it, and the data left behind. Pure. */
3533
- function cascadeDelete(data, kind, id) {
3534
- const portfolioIds = new Set(kind === "portfolio" ? [id] : []);
3535
- const programIds = new Set(kind === "program" ? [id] : data.programs.filter((p) => portfolioIds.has(p.portfolioId)).map((p) => p.id));
3536
- const projectIds = new Set(kind === "project" ? [id] : data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id));
3537
- const taskIds = new Set(data.tasks.filter((t) => projectIds.has(t.projectId)).map((t) => t.id));
3538
- const milestones = data.milestones.filter((m) => !programIds.has(m.programId)).map((m) => withoutBlockers(m, projectIds));
3539
- const constraints = (data.constraints ?? []).filter((c) => !programIds.has(c.programId));
3540
- const raidItems = data.raidItems.filter((r) => !(r.portfolioId && portfolioIds.has(r.portfolioId)) && !(r.programId && programIds.has(r.programId)) && !(r.projectId && projectIds.has(r.projectId)));
3541
- const boardConfigs = Object.fromEntries(Object.entries(data.boardConfigs ?? {}).filter(([k]) => !projectIds.has(k)));
3542
- return {
3543
- data: {
3544
- portfolios: data.portfolios.filter((p) => !portfolioIds.has(p.id)),
3545
- programs: data.programs.filter((p) => !programIds.has(p.id)).map((p) => withoutBlockers(p, programIds)),
3546
- projects: data.projects.filter((p) => !projectIds.has(p.id)).map((p) => withoutBlockers(p, projectIds)),
3547
- tasks: data.tasks.filter((t) => !taskIds.has(t.id)).map((t) => withoutBlockers(t, taskIds)),
3548
- milestones,
3549
- constraints,
3550
- raidItems,
3551
- comments: data.comments.filter((c) => !taskIds.has(c.taskId)),
3552
- attachments: (data.attachments ?? []).filter((a) => !taskIds.has(a.taskId)),
3553
- boardConfigs,
3554
- ropes: (data.ropes ?? []).map((r) => ({
3555
- ...r,
3556
- taskIds: r.taskIds.filter((t) => !taskIds.has(t))
3557
- })),
3558
- spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId)),
3559
- reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId))
3560
- },
3561
- counts: {
3562
- programs: programIds.size,
3563
- projects: projectIds.size,
3564
- tasks: taskIds.size,
3565
- milestones: data.milestones.length - milestones.length,
3566
- raidItems: data.raidItems.length - raidItems.length,
3567
- constraints: (data.constraints ?? []).length - constraints.length
3568
- }
3569
- };
3570
3576
  }
3571
- /** "2 projects, 14 tasks, 3 milestones and 2 risks or issues" — or "" when nothing else goes. */
3572
- function cascadeSummary(counts, kind) {
3573
- const bits = [];
3574
- const n = (count, one, many) => count > 0 ? `${count} ${count === 1 ? one : many}` : null;
3575
- if (kind === "portfolio") bits.push(n(counts.programs, "program", "programs") ?? "");
3576
- if (kind !== "project") bits.push(n(counts.projects, "project", "projects") ?? "");
3577
- bits.push(n(counts.tasks, "task", "tasks") ?? "");
3578
- bits.push(n(counts.milestones, "milestone", "milestones") ?? "");
3579
- bits.push(n(counts.raidItems, "logged risk, issue or decision", "logged risks, issues and decisions") ?? "");
3580
- const list = bits.filter(Boolean);
3581
- if (list.length === 0) return "";
3582
- if (list.length === 1) return list[0];
3583
- return `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`;
3577
+ /** What happened, a sentence each: the slips and their causes, the risks that came true, who carried the most, the money. */
3578
+ function voyageStory(log) {
3579
+ const out = [];
3580
+ const planned = `Planned ${md$2(log.plannedStart)} to ${md$2(log.plannedEnd)}`;
3581
+ out.push(log.done ? `${planned}; finished ${md$2(log.finished)}.` : `${planned}; not Done yet.`);
3582
+ const grew = barnaclesHeadline(log.barnacles);
3583
+ if (grew) {
3584
+ const who = log.barnacles.adders.filter((a) => a.personId);
3585
+ const by = who.length ? ` (${who.map((a) => `${a.name} ${a.added}`).join(", ")})` : "";
3586
+ const days = barnaclesDaysLine(log.barnacles);
3587
+ out.push(`Barnacles: ${grew}${by}.${days ? ` ${days}` : ""}`);
3588
+ }
3589
+ for (const s of log.slips) {
3590
+ const late = `“${s.task.title}” finished ${plural$4(s.daysLate, "day")} late`;
3591
+ out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md$2(s.waitedOn.completedAt)}.` : `${late}.`);
3592
+ }
3593
+ for (const r of log.risks.filter((x) => x.happened)) out.push(`The risk “${r.risk.title}” came true${r.guessed ? " (an issue names it)" : ""}.`);
3594
+ const against = charterSentence(log.charter);
3595
+ if (against) out.push(against);
3596
+ if (log.decisions.length) {
3597
+ const changed = log.decisions.filter((d) => d.status === "superseded").length;
3598
+ out.push(`${plural$4(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
3599
+ }
3600
+ const top = log.carriers[0];
3601
+ if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural$4(top.closed, "task")}, the most on the crew.`);
3602
+ else if (top) out.push(`${top.person.name} closed ${plural$4(top.closed, "task")}.`);
3603
+ if (log.budget > 0) {
3604
+ const pct = Math.round(log.spent / log.budget * 100);
3605
+ out.push(`Spent ${pct}% of the budget.`);
3606
+ }
3607
+ return out;
3584
3608
  }
3585
- var init_cascade = __esmMin((() => {
3586
- init_blockers();
3587
- }));
3588
3609
 
3589
3610
  //#endregion
3590
- //#region src/lib/forecast.ts
3591
- init_cascade();
3611
+ //#region src/lib/dead-reckoning.ts
3592
3612
  init_date_fns();
3593
- const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
3613
+ init_date_text();
3614
+ init_format();
3615
+ init_premortem();
3616
+ const DAY = (iso) => parseIso(iso.slice(0, 10));
3617
+ const span$1 = (a, b) => differenceInCalendarDays(DAY(b), DAY(a));
3618
+ /** Projects marked Done that have tasks to copy, most recently finished first. */
3619
+ function finishedProjects(data) {
3620
+ return data.projects.filter((p) => p.health === "done" && tasksOf(data.tasks, p.id).length > 0).map((p) => ({
3621
+ p,
3622
+ at: finishedOn(p, tasksOf(data.tasks, p.id))
3623
+ })).sort((a, b) => b.at.localeCompare(a.at)).map((x) => x.p);
3624
+ }
3594
3625
  /**
3595
- * A straight-line estimate at completion from the burn so far: spend divided
3596
- * by the share of the schedule elapsed. Null before anything is spent or
3597
- * before the schedule starts, since there is nothing to project yet. After
3598
- * the end date the estimate is the spend itself.
3626
+ * The finished projects a new one sounds like, best first: those sharing a
3627
+ * meaningful word of its name or description with their name or
3628
+ * description, the same program counting as one more.
3599
3629
  */
3600
- function forecastAtCompletion(item, now) {
3601
- if (item.spent <= 0) return null;
3602
- const total = differenceInCalendarDays(day$5(item.endDate), day$5(item.startDate)) + 1;
3603
- const gone = differenceInCalendarDays(now, day$5(item.startDate)) + 1;
3604
- if (total <= 0 || gone <= 0) return null;
3605
- const elapsed = Math.min(1, gone / total);
3606
- const eac = Math.round(item.spent / elapsed);
3630
+ function looksLike(data, name, opts = {}) {
3631
+ const want = contentWords(`${name} ${opts.description ?? ""}`);
3632
+ if (want.size === 0) return [];
3633
+ const out = [];
3634
+ for (const project of finishedProjects(data)) {
3635
+ const theirs = contentWords(`${project.name} ${project.description}`);
3636
+ let score = 0;
3637
+ for (const w of want) if (theirs.has(w)) score += 1;
3638
+ if (score === 0) continue;
3639
+ if (opts.programId && project.programId === opts.programId) score += 1;
3640
+ out.push({
3641
+ project,
3642
+ score
3643
+ });
3644
+ }
3645
+ return out.sort((a, b) => b.score - a.score);
3646
+ }
3647
+ /** The finished project's shape as it actually ran, ready to copy. */
3648
+ function reckon(data, fromId) {
3649
+ const log = voyageLog(data, fromId);
3650
+ if (!log) return null;
3651
+ const from = log.project;
3652
+ const start = log.plannedStart;
3653
+ const took = Math.max(1, span$1(start, log.finished));
3654
+ const planned = Math.max(1, span$1(start, log.plannedEnd));
3655
+ const half = Math.floor(planned / 2);
3656
+ const tasks = [];
3657
+ const slips = [];
3658
+ const own = [...tasksOf(data.tasks, fromId)].sort((a, b) => (a.number ?? 0) - (b.number ?? 0) || a.createdAt.localeCompare(b.createdAt));
3659
+ for (const t of own) {
3660
+ const actual = t.completedAt ? span$1(start, t.completedAt) : took;
3661
+ const dueIn = Math.min(took, Math.max(0, actual));
3662
+ tasks.push({
3663
+ title: t.title,
3664
+ description: t.description,
3665
+ priority: t.priority,
3666
+ dueIn,
3667
+ addedLate: span$1(start, t.createdAt) > half
3668
+ });
3669
+ if (t.dueDate && t.completedAt) {
3670
+ const plannedIn = span$1(start, t.dueDate);
3671
+ if (dueIn > plannedIn) slips.push({
3672
+ title: t.title,
3673
+ planned: Math.max(0, plannedIn),
3674
+ took: dueIn
3675
+ });
3676
+ }
3677
+ }
3678
+ slips.sort((a, b) => b.took - b.planned - (a.took - a.planned));
3607
3679
  return {
3608
- eac,
3609
- variance: item.budget - eac,
3610
- elapsed,
3611
- tone: item.budget <= 0 ? "over" : eac > item.budget * 1.05 ? "over" : eac > item.budget * .95 ? "tight" : "under"
3680
+ from,
3681
+ planned,
3682
+ took,
3683
+ tasks,
3684
+ risks: log.risks.filter((r) => r.happened).map(({ risk }) => ({
3685
+ title: risk.title,
3686
+ description: risk.description,
3687
+ probability: risk.probability ?? null,
3688
+ impact: risk.impact ?? null,
3689
+ mitigation: risk.mitigation ?? ""
3690
+ })),
3691
+ slips: slips.slice(0, 3),
3692
+ addedLate: tasks.filter((t) => t.addedLate).length
3612
3693
  };
3613
3694
  }
3614
- /** Sum of the children's estimates, using spend where a child has no forecast yet. */
3615
- function forecastRollup(items, budget, now) {
3616
- const eac = items.map((i) => forecastAtCompletion(i, now)?.eac ?? i.spent).reduce((a, b) => a + b, 0);
3617
- if (eac <= 0) return null;
3618
- const elapsed = Math.max(0, ...items.map((i) => forecastAtCompletion(i, now)?.elapsed ?? 0));
3695
+ /** "6 weeks", "10 days": a length the way people say it. */
3696
+ function lengthText(days) {
3697
+ if (days >= 14 && days % 7 <= 1) return `${Math.round(days / 7)} weeks`;
3698
+ if (days >= 21) return `about ${Math.round(days / 7)} weeks`;
3699
+ return `${days} day${days === 1 ? "" : "s"}`;
3700
+ }
3701
+ /** The one line under a match: "Planned from actuals: about 6 weeks, not the 4 Spring catalogue was planned for." */
3702
+ function reckoningLine(r) {
3703
+ if (r.took === r.planned) return `It ran to plan: ${lengthText(r.took)}.`;
3704
+ return `Planned from what really happened: ${lengthText(r.took)}, not the ${lengthText(r.planned)} ${r.from.name} was planned for.`;
3705
+ }
3706
+ /** The new project's end date, as long after its start as the old one really ran. */
3707
+ function reckonedEnd(r, startDate) {
3708
+ return todayISO(addDays(DAY(startDate), r.took));
3709
+ }
3710
+ /** Puts the reckoning's tasks and risks into the new project, dated from its start. */
3711
+ function applyReckoning(actions, projectId, r, startDate) {
3712
+ for (const t of r.tasks) actions.addTask({
3713
+ projectId,
3714
+ title: t.title,
3715
+ description: t.description,
3716
+ status: "todo",
3717
+ priority: t.priority,
3718
+ assigneeId: null,
3719
+ dueDate: todayISO(addDays(DAY(startDate), t.dueIn))
3720
+ }, {
3721
+ allowOverCap: true,
3722
+ allowBlocked: true
3723
+ });
3724
+ for (const k of r.risks) actions.addRaidItem({
3725
+ kind: "risk",
3726
+ projectId,
3727
+ title: k.title,
3728
+ description: k.description ? `${k.description}\n\nThis came true on ${r.from.name}.` : `This came true on ${r.from.name}.`,
3729
+ probability: k.probability,
3730
+ impact: k.impact,
3731
+ mitigation: k.mitigation
3732
+ });
3619
3733
  return {
3620
- eac,
3621
- variance: budget - eac,
3622
- elapsed,
3623
- tone: budget <= 0 ? "over" : eac > budget * 1.05 ? "over" : eac > budget * .95 ? "tight" : "under"
3734
+ tasks: r.tasks.length,
3735
+ risks: r.risks.length
3624
3736
  };
3625
3737
  }
3626
3738
 
3627
3739
  //#endregion
3628
- //#region src/lib/soundings.ts
3629
- /** Why a number can't be an answer, or null when it can: half days up to SOUNDING_MAX_DAYS. */
3630
- function daysProblem(days) {
3631
- if (!Number.isFinite(days) || days <= 0) return "Give a number of days above 0, like 3 or 0.5.";
3632
- if (days > 250) return `Keep it to ${250} working days or fewer.`;
3633
- if (Math.round(days * 2) !== days * 2) return "Use whole or half days, like 3 or 2.5.";
3634
- return null;
3740
+ //#region src/lib/raid.ts
3741
+ /** Still needs someone's attention: not closed, resolved, decided or superseded. */
3742
+ function isActive(item) {
3743
+ return item.status === "open" || item.status === "watching" || item.status === "mitigated" || item.status === "proposed";
3635
3744
  }
3636
- /** Each asked person's latest answer to the task's current sounding, oldest first. */
3637
- function answersFor(reflections, task) {
3638
- const s = task.sounding;
3639
- if (!s) return [];
3640
- const crew = new Set(s.crewIds);
3641
- const latest = /* @__PURE__ */ new Map();
3642
- for (const r of reflections ?? []) {
3643
- if (r.kind !== "sounding" || r.taskId !== task.id || typeof r.days !== "number") continue;
3644
- if (r.createdAt < s.askedAt || !crew.has(r.authorId)) continue;
3645
- const had = latest.get(r.authorId);
3646
- if (!had || r.createdAt >= had.createdAt) latest.set(r.authorId, r);
3647
- }
3648
- return [...latest.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3745
+ function clampScale(n) {
3746
+ if (typeof n !== "number" || !Number.isFinite(n)) return null;
3747
+ return Math.min(5, Math.max(1, Math.round(n)));
3649
3748
  }
3650
- /** Middle of the guesses, to the half day. */
3651
- function middleOf(days) {
3652
- if (days.length === 0) return null;
3653
- const sorted = [...days].sort((a, b) => a - b);
3654
- const mid = Math.floor(sorted.length / 2);
3655
- const m = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3656
- return Math.round(m * 2) / 2;
3749
+ function impactFromSeverity(s) {
3750
+ return s === "high" ? 4 : s === "medium" ? 3 : 2;
3657
3751
  }
3658
- /**
3659
- * Guesses far enough apart to talk about first: the highest at least twice
3660
- * the lowest and three or more days from it. 3 against 5 is agreement; 2
3661
- * against 15, or 5 against 10, is two people seeing different work.
3662
- */
3663
- function isWide(days) {
3664
- if (days.length < 2) return false;
3665
- const low = Math.min(...days);
3666
- const high = Math.max(...days);
3667
- return high - low >= 3 && high >= low * 2;
3752
+ function severityFromImpact(impact) {
3753
+ if (impact == null) return "medium";
3754
+ return impact >= 4 ? "high" : impact === 3 ? "medium" : "low";
3668
3755
  }
3669
- /** Where a task's sounding stands, or null when nobody asked. */
3670
- function readSounding(task, reflections, now) {
3671
- const s = task.sounding;
3672
- if (!s) return null;
3673
- const answers = answersFor(reflections, task);
3674
- const by = new Set(answers.map((a) => a.authorId));
3675
- const waitingOn = s.crewIds.filter((id) => !by.has(id));
3676
- const early = Boolean(s.shownAt && Date.parse(s.shownAt) <= now.getTime());
3677
- const shown = answers.length > 0 && (waitingOn.length === 0 || early);
3678
- const days = answers.map((a) => a.days);
3679
- const wide = shown && isWide(days);
3680
- const middle = middleOf(days);
3681
- const settled = s.settledDays ?? null;
3756
+ /** Risk: probability × impact (1–25). Issues and decisions have no score. */
3757
+ function scoreOf(item) {
3758
+ if (item.kind !== "risk" || item.probability == null || item.impact == null) return null;
3759
+ return item.probability * item.impact;
3760
+ }
3761
+ function scoreBand(score) {
3762
+ return score >= 12 ? "high" : score >= 6 ? "medium" : "low";
3763
+ }
3764
+ /** Fill defaults and keep severity, impact, status and kind consistent. Safe on old rows and on fresh input. */
3765
+ function normalizeRaidItem(r) {
3766
+ const kind = r.kind === "issue" || r.kind === "decision" ? r.kind : "risk";
3767
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3768
+ const impact = kind === "decision" ? null : clampScale(r.impact) ?? (r.impact === void 0 && r.severity ? impactFromSeverity(r.severity) : null);
3769
+ const probability = kind === "risk" ? clampScale(r.probability) : null;
3770
+ const allowed = STATUSES$1[kind];
3771
+ const status = r.status && allowed.includes(r.status) ? r.status : allowed[0];
3682
3772
  return {
3683
- sounding: s,
3684
- crewIds: s.crewIds,
3685
- answers,
3686
- waitingOn,
3687
- shown,
3688
- low: days.length ? Math.min(...days) : null,
3689
- high: days.length ? Math.max(...days) : null,
3690
- middle,
3691
- wide,
3692
- estimate: settled ?? (shown && !wide ? middle : null)
3773
+ id: r.id,
3774
+ kind,
3775
+ portfolioId: r.portfolioId ?? null,
3776
+ programId: r.programId ?? null,
3777
+ projectId: r.projectId ?? null,
3778
+ title: r.title ?? "",
3779
+ description: r.description ?? "",
3780
+ severity: kind === "decision" ? "low" : severityFromImpact(impact),
3781
+ status,
3782
+ ownerId: r.ownerId ?? null,
3783
+ dueDate: r.dueDate ?? null,
3784
+ probability,
3785
+ impact,
3786
+ mitigation: r.mitigation ?? "",
3787
+ contingency: r.contingency ?? "",
3788
+ resolution: r.resolution ?? "",
3789
+ rationale: r.rationale ?? "",
3790
+ decidedById: r.decidedById ?? null,
3791
+ decidedAt: r.decidedAt ?? null,
3792
+ notes: r.notes ?? "",
3793
+ createdById: r.createdById ?? null,
3794
+ createdAt: r.createdAt ?? now,
3795
+ updatedAt: r.updatedAt ?? r.createdAt ?? now,
3796
+ updates: Array.isArray(r.updates) ? r.updates : [],
3797
+ scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
3798
+ ...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {},
3799
+ ...kind === "decision" ? decisionFields$1(r) : {},
3800
+ ...ids(r.followerIds).length ? { followerIds: ids(r.followerIds) } : {}
3693
3801
  };
3694
3802
  }
3695
- /** What one person may see: their own answer always; everyone's once shown. */
3696
- function visibleAnswers(read, personId) {
3697
- return read.shown ? read.answers : read.answers.filter((a) => a.authorId === personId);
3698
- }
3699
- /**
3700
- * The answers the forecast draws from for an open task: the settled number
3701
- * alone once there is one, otherwise every answer in so far (the spread is
3702
- * the point, and the forecast names nobody's guess).
3703
- */
3704
- function soundingDraws(task, reflections) {
3705
- const s = task.sounding;
3706
- if (!s) return [];
3707
- if (s.settledDays != null) return [s.settledDays];
3708
- return answersFor(reflections, task).map((a) => a.days);
3803
+ /** A revisit condition that still says something, or null. */
3804
+ function cleanRevisit(v) {
3805
+ if (!v || typeof v !== "object") return null;
3806
+ const r = v;
3807
+ const str = (x) => typeof x === "string" && x ? x : null;
3808
+ if (r.kind === "date" && str(r.on)) return {
3809
+ kind: "date",
3810
+ on: str(r.on).slice(0, 10)
3811
+ };
3812
+ if (r.kind === "risk" && str(r.riskId)) return {
3813
+ kind: "risk",
3814
+ riskId: str(r.riskId)
3815
+ };
3816
+ if (r.kind === "milestone" && str(r.milestoneId) && str(r.due)) return {
3817
+ kind: "milestone",
3818
+ milestoneId: str(r.milestoneId),
3819
+ due: str(r.due).slice(0, 10)
3820
+ };
3821
+ return null;
3709
3822
  }
3710
- /**
3711
- * Calendar days from `from` until `days` working days have passed: Monday
3712
- * to Friday, or the days `works` says (a person's holidays and time away,
3713
- * src/lib/holidays.ts). Gives up after two years.
3714
- */
3715
- function workingDaysAhead(days, from, works) {
3716
- let left = Math.ceil(days);
3717
- let d = 0;
3718
- while (left > 0 && d < 730) {
3719
- d += 1;
3720
- const at = addDays(from, d);
3721
- const wd = at.getDay();
3722
- if (works ? works(at) : wd !== 0 && wd !== 6) left -= 1;
3723
- }
3724
- return d;
3823
+ /** A decision's own fields, kept only where they say something so older rows stay as they were. */
3824
+ function decisionFields$1(r) {
3825
+ const out = {};
3826
+ if (r.turnedDown?.trim()) out.turnedDown = r.turnedDown;
3827
+ const tasks = ids(r.taskIds);
3828
+ if (tasks.length) out.taskIds = tasks;
3829
+ const risks = ids(r.riskIds);
3830
+ if (risks.length) out.riskIds = risks;
3831
+ const revisit = cleanRevisit(r.revisitIf);
3832
+ if (revisit) out.revisitIf = revisit;
3833
+ if (r.supersedesId) out.supersedesId = r.supersedesId;
3834
+ return out;
3725
3835
  }
3726
- /** "half a day", "1 day", "2.5 days". */
3727
- function daysText(days) {
3728
- if (days === .5) return "half a day";
3729
- return `${days} day${days === 1 ? "" : "s"}`;
3836
+ /** Active first, then by score, then most recently touched. */
3837
+ function sortRaid(items) {
3838
+ return [...items].sort((a, b) => {
3839
+ const act = Number(isActive(b)) - Number(isActive(a));
3840
+ if (act) return act;
3841
+ const sa = scoreOf(a) ?? (a.impact ?? 0) * 2;
3842
+ const sb = scoreOf(b) ?? (b.impact ?? 0) * 2;
3843
+ if (sb !== sa) return sb - sa;
3844
+ return b.updatedAt.localeCompare(a.updatedAt);
3845
+ });
3730
3846
  }
3731
3847
  /**
3732
- * Who a new sounding asks unless you pick: you, the task's owner, and the
3733
- * people with open work on its project, agents left out.
3848
+ * Live risks rated on both scales, placed on the 5×5 grid: rows run from
3849
+ * impact 5 down to 1, columns from probability 1 up to 5, so the top-right
3850
+ * corner is the worst place to be.
3734
3851
  */
3735
- function suggestedCrew(data, task, meId) {
3736
- const people = new Map(data.people.filter((p) => p.kind !== "agent").map((p) => [p.id, p]));
3737
- const out = /* @__PURE__ */ new Set();
3738
- if (people.has(meId)) out.add(meId);
3739
- if (task.assigneeId && people.has(task.assigneeId)) out.add(task.assigneeId);
3740
- for (const t of data.tasks) {
3741
- if (out.size >= 6) break;
3742
- if (t.projectId !== task.projectId || t.status === "done" || !t.assigneeId) continue;
3743
- if (people.has(t.assigneeId)) out.add(t.assigneeId);
3744
- }
3745
- return [...out];
3746
- }
3747
- /** "Close enough: about 4 days", or why it needs talking through. */
3748
- function soundingVerdict(read) {
3749
- if (read.sounding.settledDays != null) return `Settled on ${daysText(read.sounding.settledDays)}.`;
3750
- if (read.wide) return `Talk about this first: guesses run from ${daysText(read.low)} to ${daysText(read.high)}, so someone sees work the others don't.`;
3751
- return `Close enough: about ${daysText(read.estimate)}.`;
3752
- }
3753
- /** Open tasks whose sounding waits on this person's answer. */
3754
- function soundingsWaitingOn(data, personId, now) {
3755
- return data.tasks.filter((t) => {
3756
- if (t.status === "done" || !t.sounding?.crewIds.includes(personId)) return false;
3757
- return readSounding(t, data.reflections, now).waitingOn.includes(personId);
3758
- });
3852
+ function heatGrid(items) {
3853
+ const rows = [];
3854
+ for (let impact = 5; impact >= 1; impact -= 1) {
3855
+ const row = [];
3856
+ for (let probability = 1; probability <= 5; probability += 1) row.push({
3857
+ probability,
3858
+ impact,
3859
+ band: scoreBand(probability * impact),
3860
+ items: []
3861
+ });
3862
+ rows.push(row);
3863
+ }
3864
+ for (const item of items) {
3865
+ if (item.kind !== "risk" || !isActive(item)) continue;
3866
+ const p = clampScale(item.probability);
3867
+ const i = clampScale(item.impact);
3868
+ if (p == null || i == null) continue;
3869
+ rows[5 - i][p - 1].items.push(item);
3870
+ }
3871
+ return rows;
3759
3872
  }
3760
- var SOUNDING_MAX_DAYS;
3761
- var init_soundings = __esmMin((() => {
3762
- init_date_fns();
3763
- SOUNDING_MAX_DAYS = 250;
3764
- }));
3765
-
3766
- //#endregion
3767
- //#region src/lib/countries.ts
3768
- /** A country code Harbor has holidays for, upper-cased; null otherwise. */
3769
- function countryCode(text) {
3770
- if (!text) return null;
3771
- const t = text.trim();
3772
- const up = t.toUpperCase();
3773
- if (KNOWN.has(up)) return up;
3774
- if (up === "UK") return "GB";
3775
- if (up === "USA") return "US";
3776
- return COUNTRIES.find((c) => c.name.toLowerCase() === t.toLowerCase())?.code ?? null;
3873
+ /** True when a risk sits in the given cell of the grid. */
3874
+ function inHeatCell(item, probability, impact) {
3875
+ return item.kind === "risk" && item.probability === probability && item.impact === impact;
3777
3876
  }
3778
- var COUNTRIES, KNOWN, countryName;
3779
- var init_countries = __esmMin((() => {
3780
- COUNTRIES = [
3781
- {
3782
- code: "AU",
3783
- name: "Australia"
3784
- },
3785
- {
3786
- code: "BR",
3787
- name: "Brazil"
3788
- },
3789
- {
3790
- code: "CA",
3791
- name: "Canada"
3792
- },
3793
- {
3794
- code: "FR",
3795
- name: "France"
3796
- },
3797
- {
3798
- code: "DE",
3799
- name: "Germany"
3800
- },
3801
- {
3802
- code: "IE",
3803
- name: "Ireland"
3804
- },
3805
- {
3806
- code: "IT",
3807
- name: "Italy"
3808
- },
3809
- {
3810
- code: "MX",
3811
- name: "Mexico"
3812
- },
3813
- {
3814
- code: "NL",
3815
- name: "Netherlands"
3816
- },
3817
- {
3818
- code: "NZ",
3819
- name: "New Zealand"
3820
- },
3821
- {
3822
- code: "ES",
3823
- name: "Spain"
3877
+ var KIND_LABEL, STATUS_LABEL$1, STATUSES$1, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY, ids;
3878
+ var init_raid = __esmMin((() => {
3879
+ KIND_LABEL = {
3880
+ risk: "Risk",
3881
+ issue: "Issue",
3882
+ decision: "Decision"
3883
+ };
3884
+ STATUS_LABEL$1 = {
3885
+ open: "Open",
3886
+ watching: "Watching",
3887
+ mitigated: "Mitigated",
3888
+ resolved: "Resolved",
3889
+ closed: "Closed",
3890
+ proposed: "Proposed",
3891
+ decided: "Decided",
3892
+ superseded: "Superseded"
3893
+ };
3894
+ STATUSES$1 = {
3895
+ risk: [
3896
+ "open",
3897
+ "watching",
3898
+ "mitigated",
3899
+ "closed"
3900
+ ],
3901
+ issue: [
3902
+ "open",
3903
+ "resolved",
3904
+ "closed"
3905
+ ],
3906
+ decision: [
3907
+ "proposed",
3908
+ "decided",
3909
+ "superseded"
3910
+ ]
3911
+ };
3912
+ PROBABILITY_LABEL = [
3913
+ "",
3914
+ "Rare",
3915
+ "Unlikely",
3916
+ "Possible",
3917
+ "Likely",
3918
+ "Almost certain"
3919
+ ];
3920
+ IMPACT_LABEL = [
3921
+ "",
3922
+ "Minor",
3923
+ "Small",
3924
+ "Moderate",
3925
+ "Major",
3926
+ "Severe"
3927
+ ];
3928
+ FIELD_COPY = {
3929
+ risk: {
3930
+ description: "What could happen",
3931
+ mitigation: "How we reduce it",
3932
+ contingency: "If it happens",
3933
+ dueDate: "Review by"
3824
3934
  },
3825
- {
3826
- code: "GB",
3827
- name: "United Kingdom"
3935
+ issue: {
3936
+ description: "What's going on",
3937
+ resolution: "How it gets resolved",
3938
+ dueDate: "Needs an answer by"
3828
3939
  },
3829
- {
3830
- code: "US",
3831
- name: "United States"
3940
+ decision: {
3941
+ description: "Context",
3942
+ resolution: "The decision",
3943
+ rationale: "Why",
3944
+ dueDate: "Decide by"
3832
3945
  }
3946
+ };
3947
+ ids = (v) => Array.isArray(v) ? [...new Set(v.filter((x) => typeof x === "string" && !!x))] : [];
3948
+ }));
3949
+
3950
+ //#endregion
3951
+ //#region src/lib/swatches.ts
3952
+ var SWATCHES, HEALTH_LABEL$1, TASK_COLUMNS, TASK_STATUS_LABEL, PRIORITY_LABEL, CONSTRAINT_KIND_LABEL, CONSTRAINT_KIND_HINT;
3953
+ var init_swatches = __esmMin((() => {
3954
+ SWATCHES = [
3955
+ "pine",
3956
+ "clay",
3957
+ "sage",
3958
+ "sand",
3959
+ "slate",
3960
+ "sea",
3961
+ "rust",
3962
+ "moss"
3833
3963
  ];
3834
- KNOWN = new Set(COUNTRIES.map((c) => c.code));
3835
- countryName = (code) => COUNTRIES.find((c) => c.code === code)?.name ?? code;
3964
+ HEALTH_LABEL$1 = {
3965
+ "on-track": "On track",
3966
+ "at-risk": "At risk",
3967
+ "off-track": "Off track",
3968
+ paused: "Paused",
3969
+ done: "Done"
3970
+ };
3971
+ TASK_COLUMNS = [
3972
+ "backlog",
3973
+ "todo",
3974
+ "doing",
3975
+ "review",
3976
+ "done"
3977
+ ];
3978
+ TASK_STATUS_LABEL = {
3979
+ backlog: "Backlog",
3980
+ todo: "To do",
3981
+ doing: "In play",
3982
+ review: "Review",
3983
+ done: "Done"
3984
+ };
3985
+ PRIORITY_LABEL = {
3986
+ low: "Low",
3987
+ medium: "Medium",
3988
+ high: "High",
3989
+ urgent: "Urgent"
3990
+ };
3991
+ CONSTRAINT_KIND_LABEL = {
3992
+ resource: "A person",
3993
+ policy: "A policy",
3994
+ external: "Outside the building",
3995
+ collision: "A collision"
3996
+ };
3997
+ CONSTRAINT_KIND_HINT = {
3998
+ resource: "Someone whose calendar sets the date.",
3999
+ policy: "A rule that creates the wait.",
4000
+ external: "A vendor, a city, a review board.",
4001
+ collision: "Two things sharing one window."
4002
+ };
3836
4003
  }));
3837
4004
 
3838
4005
  //#endregion
3839
- //#region src/lib/holidays.ts
3840
- init_soundings();
4006
+ //#region src/tui/create.ts
3841
4007
  init_date_fns();
3842
4008
  init_format();
3843
- init_countries();
3844
- const iso$4 = (y, m, d) => todayISO(new Date(y, m - 1, d, 12));
3845
- const weekday = (day) => (/* @__PURE__ */ new Date(`${day}T12:00:00`)).getDay();
3846
- const plus = (day, n) => todayISO(addDays(/* @__PURE__ */ new Date(`${day}T12:00:00`), n));
3847
- const isWeekend = (day) => {
3848
- const wd = weekday(day);
3849
- return wd === 0 || wd === 6;
3850
- };
3851
- /** Easter Sunday (the anonymous Gregorian algorithm). */
3852
- function easter(year) {
3853
- const a = year % 19;
3854
- const b = Math.floor(year / 100);
3855
- const c = year % 100;
3856
- const d = Math.floor(b / 4);
3857
- const e = b % 4;
3858
- const f = Math.floor((b + 8) / 25);
3859
- const g = Math.floor((b - f + 1) / 3);
3860
- const h = (19 * a + b - d - g + 15) % 30;
3861
- const i = Math.floor(c / 4);
3862
- const k = c % 4;
3863
- const l = (32 + 2 * e + 2 * i - h - k) % 7;
3864
- const m = Math.floor((a + 11 * h + 22 * l) / 451);
3865
- const month = Math.floor((h + l - 7 * m + 114) / 31);
3866
- const day = (h + l - 7 * m + 114) % 31 + 1;
3867
- return iso$4(year, month, day);
4009
+ init_indexes();
4010
+ init_date_text();
4011
+ init_raid();
4012
+ init_swatches();
4013
+ const scale = (labels) => [
4014
+ 1,
4015
+ 2,
4016
+ 3,
4017
+ 4,
4018
+ 5
4019
+ ].map((n) => ({
4020
+ label: `${n} ${labels[n]}`,
4021
+ value: n
4022
+ }));
4023
+ const plus = (today, days) => format(addDays(parseIso(today), days), "yyyy-MM-dd");
4024
+ /** The colour a new project or program takes: the first one its neighbours don't use. */
4025
+ function nextSwatch(used) {
4026
+ return SWATCHES.find((s) => !used.includes(s)) ?? SWATCHES[0];
4027
+ }
4028
+ /**
4029
+ * "Start empty", then the finished projects the new name sounds like (as the
4030
+ * website's "This looks like …"), then the other finished ones.
4031
+ */
4032
+ function planFromChoices(data, finished, name, programId) {
4033
+ const like = looksLike(data, name, { programId }).map((l) => l.project);
4034
+ const rest = finished.filter((p) => !like.includes(p));
4035
+ const choice = (p, sounds) => {
4036
+ const r = reckon(data, p.id);
4037
+ return {
4038
+ label: `${sounds ? "This looks like" : "Plan from"} ${p.name}`,
4039
+ value: p.id,
4040
+ hint: `done ${shortDate(finishedOn(p, tasksOf(data.tasks, p.id)))} · ${tasksOf(data.tasks, p.id).length} tasks${r ? ` · ${reckoningLine(r)}` : ""}`
4041
+ };
4042
+ };
4043
+ return [
4044
+ {
4045
+ label: "Start empty",
4046
+ value: null,
4047
+ hint: "or copy a finished project's shape as it really ran"
4048
+ },
4049
+ ...like.map((p) => choice(p, true)),
4050
+ ...rest.map((p) => choice(p, false))
4051
+ ];
3868
4052
  }
3869
- /** The nth given weekday (0 Sunday) of a month; n = -1 for the last. */
3870
- function nth(year, month, dow, n) {
3871
- if (n > 0) {
3872
- const first = iso$4(year, month, 1);
3873
- const shift = (dow - weekday(first) + 7) % 7;
3874
- return plus(first, shift + (n - 1) * 7);
3875
- }
3876
- const last = iso$4(year, month + 1, 0);
3877
- return plus(last, -((weekday(last) - dow + 7) % 7));
3878
- }
3879
- /** The given weekday on or before a day. */
3880
- const onOrBefore = (day, dow) => plus(day, -((weekday(day) - dow + 7) % 7));
3881
- const fixed = (m, d) => (y) => iso$4(y, m, d);
3882
- const fromEaster = (n) => (y) => plus(easter(y), n);
3883
- /** New Zealand's Matariki, set by law for each year. */
3884
- const MATARIKI = {
3885
- 2025: "2025-06-20",
3886
- 2026: "2026-07-10",
3887
- 2027: "2027-06-25",
3888
- 2028: "2028-07-14",
3889
- 2029: "2029-07-06",
3890
- 2030: "2030-06-21",
3891
- 2031: "2031-07-11",
3892
- 2032: "2032-07-02",
3893
- 2033: "2033-06-24",
3894
- 2034: "2034-07-07",
3895
- 2035: "2035-06-29"
3896
- };
3897
- const RULES = {
3898
- US: [
3899
- [
3900
- "New Year's Day",
3901
- fixed(1, 1),
3902
- "us"
3903
- ],
3904
- ["Martin Luther King Jr. Day", (y) => nth(y, 1, 1, 3)],
3905
- ["Presidents' Day", (y) => nth(y, 2, 1, 3)],
3906
- ["Memorial Day", (y) => nth(y, 5, 1, -1)],
3907
- [
3908
- "Juneteenth",
3909
- fixed(6, 19),
3910
- "us"
3911
- ],
3912
- [
3913
- "Independence Day",
3914
- fixed(7, 4),
3915
- "us"
3916
- ],
3917
- ["Labor Day", (y) => nth(y, 9, 1, 1)],
3918
- ["Thanksgiving", (y) => nth(y, 11, 4, 4)],
3919
- [
3920
- "Christmas Day",
3921
- fixed(12, 25),
3922
- "us"
3923
- ]
3924
- ],
3925
- CA: [
3926
- [
3927
- "New Year's Day",
3928
- fixed(1, 1),
3929
- "next"
3930
- ],
3931
- ["Good Friday", fromEaster(-2)],
3932
- ["Victoria Day", (y) => onOrBefore(iso$4(y, 5, 24), 1)],
3933
- [
3934
- "Canada Day",
3935
- fixed(7, 1),
3936
- "next"
3937
- ],
3938
- ["Labour Day", (y) => nth(y, 9, 1, 1)],
3939
- ["Thanksgiving", (y) => nth(y, 10, 1, 2)],
3940
- [
3941
- "Christmas Day",
3942
- fixed(12, 25),
3943
- "next"
3944
- ],
3945
- [
3946
- "Boxing Day",
3947
- fixed(12, 26),
3948
- "next"
3949
- ]
3950
- ],
3951
- GB: [
3952
- [
3953
- "New Year's Day",
3954
- fixed(1, 1),
3955
- "next"
3956
- ],
3957
- ["Good Friday", fromEaster(-2)],
3958
- ["Easter Monday", fromEaster(1)],
3959
- ["Early May bank holiday", (y) => nth(y, 5, 1, 1)],
3960
- ["Spring bank holiday", (y) => nth(y, 5, 1, -1)],
3961
- ["Summer bank holiday", (y) => nth(y, 8, 1, -1)],
3962
- [
3963
- "Christmas Day",
3964
- fixed(12, 25),
3965
- "next"
3966
- ],
3967
- [
3968
- "Boxing Day",
3969
- fixed(12, 26),
3970
- "next"
3971
- ]
3972
- ],
3973
- IE: [
3974
- [
3975
- "New Year's Day",
3976
- fixed(1, 1),
3977
- "next"
3978
- ],
3979
- ["St Brigid's Day", (y) => weekday(iso$4(y, 2, 1)) === 5 ? iso$4(y, 2, 1) : nth(y, 2, 1, 1)],
3980
- [
3981
- "St Patrick's Day",
3982
- fixed(3, 17),
3983
- "next"
3984
- ],
3985
- ["Easter Monday", fromEaster(1)],
3986
- ["May bank holiday", (y) => nth(y, 5, 1, 1)],
3987
- ["June bank holiday", (y) => nth(y, 6, 1, 1)],
3988
- ["August bank holiday", (y) => nth(y, 8, 1, 1)],
3989
- ["October bank holiday", (y) => nth(y, 10, 1, -1)],
3990
- [
3991
- "Christmas Day",
3992
- fixed(12, 25),
3993
- "next"
3994
- ],
3995
- [
3996
- "St Stephen's Day",
3997
- fixed(12, 26),
3998
- "next"
3999
- ]
4000
- ],
4001
- AU: [
4002
- [
4003
- "New Year's Day",
4004
- fixed(1, 1),
4005
- "next"
4006
- ],
4007
- [
4008
- "Australia Day",
4009
- fixed(1, 26),
4010
- "next"
4011
- ],
4012
- ["Good Friday", fromEaster(-2)],
4013
- ["Easter Monday", fromEaster(1)],
4014
- ["Anzac Day", fixed(4, 25)],
4015
- ["King's Birthday", (y) => nth(y, 6, 1, 2)],
4016
- [
4017
- "Christmas Day",
4018
- fixed(12, 25),
4019
- "next"
4020
- ],
4021
- [
4022
- "Boxing Day",
4023
- fixed(12, 26),
4024
- "next"
4025
- ]
4026
- ],
4027
- NZ: [
4028
- [
4029
- "New Year's Day",
4030
- fixed(1, 1),
4031
- "next"
4032
- ],
4033
- [
4034
- "Day after New Year's Day",
4035
- fixed(1, 2),
4036
- "next"
4037
- ],
4038
- [
4039
- "Waitangi Day",
4040
- fixed(2, 6),
4041
- "next"
4042
- ],
4043
- ["Good Friday", fromEaster(-2)],
4044
- ["Easter Monday", fromEaster(1)],
4045
- [
4046
- "Anzac Day",
4047
- fixed(4, 25),
4048
- "next"
4049
- ],
4050
- ["King's Birthday", (y) => nth(y, 6, 1, 1)],
4051
- ["Matariki", (y) => MATARIKI[y] ?? ""],
4052
- ["Labour Day", (y) => nth(y, 10, 1, 4)],
4053
- [
4054
- "Christmas Day",
4055
- fixed(12, 25),
4056
- "next"
4057
- ],
4058
- [
4059
- "Boxing Day",
4060
- fixed(12, 26),
4061
- "next"
4062
- ]
4063
- ],
4064
- DE: [
4065
- ["Neujahr", fixed(1, 1)],
4066
- ["Karfreitag", fromEaster(-2)],
4067
- ["Ostermontag", fromEaster(1)],
4068
- ["Tag der Arbeit", fixed(5, 1)],
4069
- ["Christi Himmelfahrt", fromEaster(39)],
4070
- ["Pfingstmontag", fromEaster(50)],
4071
- ["Tag der Deutschen Einheit", fixed(10, 3)],
4072
- ["1. Weihnachtstag", fixed(12, 25)],
4073
- ["2. Weihnachtstag", fixed(12, 26)]
4074
- ],
4075
- FR: [
4076
- ["Jour de l'an", fixed(1, 1)],
4077
- ["Lundi de Pâques", fromEaster(1)],
4078
- ["Fête du Travail", fixed(5, 1)],
4079
- ["Victoire 1945", fixed(5, 8)],
4080
- ["Ascension", fromEaster(39)],
4081
- ["Lundi de Pentecôte", fromEaster(50)],
4082
- ["Fête nationale", fixed(7, 14)],
4083
- ["Assomption", fixed(8, 15)],
4084
- ["Toussaint", fixed(11, 1)],
4085
- ["Armistice", fixed(11, 11)],
4086
- ["Noël", fixed(12, 25)]
4087
- ],
4088
- NL: [
4089
- ["Nieuwjaarsdag", fixed(1, 1)],
4090
- ["Tweede paasdag", fromEaster(1)],
4091
- ["Koningsdag", (y) => weekday(iso$4(y, 4, 27)) === 0 ? iso$4(y, 4, 26) : iso$4(y, 4, 27)],
4092
- ["Hemelvaartsdag", fromEaster(39)],
4093
- ["Tweede pinksterdag", fromEaster(50)],
4094
- ["Eerste kerstdag", fixed(12, 25)],
4095
- ["Tweede kerstdag", fixed(12, 26)]
4096
- ],
4097
- ES: [
4098
- ["Año Nuevo", fixed(1, 1)],
4099
- ["Epifanía", fixed(1, 6)],
4100
- ["Viernes Santo", fromEaster(-2)],
4101
- ["Fiesta del Trabajo", fixed(5, 1)],
4102
- ["Asunción", fixed(8, 15)],
4103
- ["Fiesta Nacional", fixed(10, 12)],
4104
- ["Todos los Santos", fixed(11, 1)],
4105
- ["Día de la Constitución", fixed(12, 6)],
4106
- ["Inmaculada Concepción", fixed(12, 8)],
4107
- ["Navidad", fixed(12, 25)]
4108
- ],
4109
- IT: [
4110
- ["Capodanno", fixed(1, 1)],
4111
- ["Epifania", fixed(1, 6)],
4112
- ["Lunedì dell'Angelo", fromEaster(1)],
4113
- ["Festa della Liberazione", fixed(4, 25)],
4114
- ["Festa del Lavoro", fixed(5, 1)],
4115
- ["Festa della Repubblica", fixed(6, 2)],
4116
- ["Ferragosto", fixed(8, 15)],
4117
- ["Ognissanti", fixed(11, 1)],
4118
- ["Immacolata Concezione", fixed(12, 8)],
4119
- ["Natale", fixed(12, 25)],
4120
- ["Santo Stefano", fixed(12, 26)]
4121
- ],
4122
- MX: [
4123
- ["Año Nuevo", fixed(1, 1)],
4124
- ["Día de la Constitución", (y) => nth(y, 2, 1, 1)],
4125
- ["Natalicio de Benito Juárez", (y) => nth(y, 3, 1, 3)],
4126
- ["Día del Trabajo", fixed(5, 1)],
4127
- ["Día de la Independencia", fixed(9, 16)],
4128
- ["Día de la Revolución", (y) => nth(y, 11, 1, 3)],
4129
- ["Navidad", fixed(12, 25)]
4130
- ],
4131
- BR: [
4132
- ["Confraternização Universal", fixed(1, 1)],
4133
- ["Carnaval", fromEaster(-48)],
4134
- ["Carnaval", fromEaster(-47)],
4135
- ["Sexta-feira Santa", fromEaster(-2)],
4136
- ["Tiradentes", fixed(4, 21)],
4137
- ["Dia do Trabalho", fixed(5, 1)],
4138
- ["Corpus Christi", fromEaster(60)],
4139
- ["Independência", fixed(9, 7)],
4140
- ["Nossa Senhora Aparecida", fixed(10, 12)],
4141
- ["Finados", fixed(11, 2)],
4142
- ["Proclamação da República", fixed(11, 15)],
4143
- ["Consciência Negra", fixed(11, 20)],
4144
- ["Natal", fixed(12, 25)]
4145
- ]
4053
+ const CREATE_TITLE = {
4054
+ portfolio: "New portfolio",
4055
+ project: "New project",
4056
+ program: "New program",
4057
+ milestone: "New milestone",
4058
+ raid: "Log a risk, issue or decision",
4059
+ spend: "Log spend"
4146
4060
  };
4147
- const cache = /* @__PURE__ */ new Map();
4148
- /** A country's public holidays in a year, by the day they're taken, oldest first. */
4149
- function holidaysIn(country, year) {
4150
- const key = `${country}|${year}`;
4151
- const had = cache.get(key);
4152
- if (had) return had;
4153
- const days = (RULES[country] ?? []).map(([name, day, move = "none"]) => ({
4154
- name,
4155
- date: day(year),
4156
- move
4157
- })).filter((h) => h.date);
4158
- const taken = new Set(days.filter((h) => !isWeekend(h.date)).map((h) => h.date));
4159
- const out = [];
4160
- for (const h of days) {
4161
- let date = h.date;
4162
- const wd = weekday(date);
4163
- if (h.move === "us" && (wd === 0 || wd === 6)) date = plus(date, wd === 6 ? -1 : 1);
4164
- else if (h.move === "next" && (wd === 0 || wd === 6)) {
4165
- date = plus(date, 1);
4166
- while (taken.has(date) || isWeekend(date)) date = plus(date, 1);
4167
- taken.add(date);
4168
- }
4169
- out.push({
4170
- date,
4171
- name: h.name,
4172
- country
4173
- });
4061
+ function stepsFor(kind, data, ctx, me) {
4062
+ const people = data.people.map((p) => ({
4063
+ label: p.name,
4064
+ value: p.id,
4065
+ hint: p.role
4066
+ }));
4067
+ const meFirst = Math.max(0, people.findIndex((p) => p.value === me));
4068
+ const finished = kind === "project" ? finishedProjects(data) : [];
4069
+ switch (kind) {
4070
+ case "portfolio": return [
4071
+ {
4072
+ key: "name",
4073
+ label: "Name",
4074
+ kind: "text",
4075
+ placeholder: "e.g. Outdoor gear"
4076
+ },
4077
+ {
4078
+ key: "ownerId",
4079
+ label: "Owner",
4080
+ kind: "pick",
4081
+ choices: people,
4082
+ initial: meFirst
4083
+ },
4084
+ {
4085
+ key: "theme",
4086
+ label: "Theme",
4087
+ kind: "text",
4088
+ placeholder: "What it's for, in a line",
4089
+ optional: true
4090
+ }
4091
+ ];
4092
+ case "project": return [
4093
+ {
4094
+ key: "name",
4095
+ label: "Name",
4096
+ kind: "text",
4097
+ placeholder: "e.g. Fall catalogue shoot"
4098
+ },
4099
+ ...ctx.programId ? [] : [{
4100
+ key: "programId",
4101
+ label: "Program",
4102
+ kind: "pick",
4103
+ choices: data.programs.map((p) => ({
4104
+ label: p.name,
4105
+ value: p.id
4106
+ }))
4107
+ }],
4108
+ ...finished.length ? [{
4109
+ key: "likeId",
4110
+ label: "Plan from",
4111
+ kind: "pick",
4112
+ choices: [],
4113
+ choicesFor: (a) => planFromChoices(data, finished, String(a.name ?? ""), ctx.programId ?? a.programId)
4114
+ }] : [],
4115
+ {
4116
+ key: "ownerId",
4117
+ label: "Lead",
4118
+ kind: "pick",
4119
+ choices: people,
4120
+ initial: meFirst
4121
+ },
4122
+ {
4123
+ key: "endDate",
4124
+ label: "Ends",
4125
+ kind: "date",
4126
+ optional: true,
4127
+ skipSays: "for 60 days from today",
4128
+ when: (a) => !a.likeId
4129
+ },
4130
+ {
4131
+ key: "endDate",
4132
+ label: "Ends",
4133
+ kind: "date",
4134
+ optional: true,
4135
+ skipSays: "as long as that one really ran",
4136
+ when: (a) => Boolean(a.likeId)
4137
+ },
4138
+ {
4139
+ key: "budget",
4140
+ label: "Budget",
4141
+ kind: "number",
4142
+ optional: true
4143
+ }
4144
+ ];
4145
+ case "program": return [
4146
+ {
4147
+ key: "name",
4148
+ label: "Name",
4149
+ kind: "text",
4150
+ placeholder: "e.g. Spring range"
4151
+ },
4152
+ ...ctx.portfolioId ? [] : [{
4153
+ key: "portfolioId",
4154
+ label: "Portfolio",
4155
+ kind: "pick",
4156
+ choices: data.portfolios.map((p) => ({
4157
+ label: p.name,
4158
+ value: p.id
4159
+ }))
4160
+ }],
4161
+ {
4162
+ key: "ownerId",
4163
+ label: "Lead",
4164
+ kind: "pick",
4165
+ choices: people,
4166
+ initial: meFirst
4167
+ },
4168
+ {
4169
+ key: "endDate",
4170
+ label: "Ends",
4171
+ kind: "date"
4172
+ }
4173
+ ];
4174
+ case "milestone": return [{
4175
+ key: "name",
4176
+ label: "Name",
4177
+ kind: "text",
4178
+ placeholder: "e.g. Samples approved"
4179
+ }, {
4180
+ key: "dueDate",
4181
+ label: "Due",
4182
+ kind: "date"
4183
+ }];
4184
+ case "raid": return [
4185
+ {
4186
+ key: "kind",
4187
+ label: "Kind",
4188
+ kind: "pick",
4189
+ choices: [
4190
+ "risk",
4191
+ "issue",
4192
+ "decision"
4193
+ ].map((k) => ({
4194
+ label: KIND_LABEL[k],
4195
+ value: k
4196
+ }))
4197
+ },
4198
+ {
4199
+ key: "title",
4200
+ label: "Title",
4201
+ kind: "text"
4202
+ },
4203
+ {
4204
+ key: "probability",
4205
+ label: "Likelihood",
4206
+ kind: "pick",
4207
+ choices: scale(PROBABILITY_LABEL),
4208
+ initial: 2,
4209
+ when: (a) => a.kind === "risk"
4210
+ },
4211
+ {
4212
+ key: "impact",
4213
+ label: "Impact",
4214
+ kind: "pick",
4215
+ choices: scale(IMPACT_LABEL),
4216
+ initial: 2,
4217
+ when: (a) => a.kind !== "decision"
4218
+ },
4219
+ ...ctx.projectId ? [] : [{
4220
+ key: "projectId",
4221
+ label: "Project",
4222
+ kind: "pick",
4223
+ choices: [{
4224
+ label: "None in particular",
4225
+ value: null
4226
+ }, ...data.projects.map((p) => ({
4227
+ label: p.name,
4228
+ value: p.id
4229
+ }))]
4230
+ }],
4231
+ {
4232
+ key: "ownerId",
4233
+ label: "Owner",
4234
+ kind: "pick",
4235
+ choices: people,
4236
+ initial: meFirst
4237
+ }
4238
+ ];
4239
+ case "spend": return [
4240
+ {
4241
+ key: "amount",
4242
+ label: "Amount",
4243
+ kind: "number"
4244
+ },
4245
+ {
4246
+ key: "date",
4247
+ label: "Date (Enter for today)",
4248
+ kind: "date",
4249
+ optional: true
4250
+ },
4251
+ {
4252
+ key: "note",
4253
+ label: "What for",
4254
+ kind: "text",
4255
+ optional: true
4256
+ }
4257
+ ];
4174
4258
  }
4175
- out.sort((a, b) => a.date.localeCompare(b.date));
4176
- cache.set(key, out);
4177
- return out;
4178
- }
4179
- /** Holidays in the given countries between two days, both included. */
4180
- function holidaysBetween(countries, from, to) {
4181
- const out = [];
4182
- const y0 = Number(from.slice(0, 4));
4183
- const y1 = Number(to.slice(0, 4));
4184
- for (const c of countries) for (let y = y0; y <= y1; y++) for (const h of holidaysIn(c, y)) if (h.date >= from && h.date <= to) out.push(h);
4185
- return out.sort((a, b) => a.date.localeCompare(b.date) || a.country.localeCompare(b.country));
4186
- }
4187
- /** Days someone is away: from the handover day up to the day before they're back. */
4188
- function awayDays(person) {
4189
- const a = person.away;
4190
- if (!a?.until || !a.since) return null;
4191
- return [a.since.slice(0, 10), plus(a.until, -1)];
4192
4259
  }
4193
- /** The harbor's calendar of who works when; see the top of this file. */
4194
- function workCalendar(data) {
4195
- const harbor = (data.workCalendar?.countries ?? []).filter((c) => countryCode(c) === c);
4196
- const people = new Map(data.people.map((p) => [p.id, p]));
4197
- const own = (id) => {
4198
- const c = id ? countryCode(people.get(id)?.country) : null;
4199
- return c ? [c] : harbor;
4200
- };
4201
- const byDay = /* @__PURE__ */ new Map();
4202
- const holidayOn = (countries, day) => {
4203
- const year = Number(day.slice(0, 4));
4204
- for (const c of countries) {
4205
- const k = `${c}|${year}`;
4206
- let days = byDay.get(k);
4207
- if (!days) {
4208
- days = new Map(holidaysIn(c, year).map((h) => [h.date, h]));
4209
- byDay.set(k, days);
4210
- }
4211
- const h = days.get(day);
4212
- if (h) return h;
4260
+ /** Make it; returns the new thing's id (or null when there's nothing to make it in). */
4261
+ function commitCreate(store, kind, v, ctx, today) {
4262
+ const s = store.getState();
4263
+ const str = (k) => typeof v[k] === "string" ? v[k] : "";
4264
+ switch (kind) {
4265
+ case "portfolio": return s.addPortfolio({
4266
+ name: str("name"),
4267
+ description: "",
4268
+ swatch: nextSwatch(s.portfolios.map((p) => p.swatch)),
4269
+ ownerId: str("ownerId"),
4270
+ budget: 0,
4271
+ ...str("theme") ? { theme: str("theme") } : {}
4272
+ });
4273
+ case "project": {
4274
+ const programId = ctx.programId ?? str("programId");
4275
+ const program = s.programs.find((p) => p.id === programId);
4276
+ if (!program) return null;
4277
+ const reckoning = str("likeId") ? reckon(s, str("likeId")) : null;
4278
+ const id = s.addProject({
4279
+ name: str("name"),
4280
+ description: "",
4281
+ swatch: nextSwatch(s.projects.filter((p) => p.programId === programId).map((p) => p.swatch)),
4282
+ ownerId: str("ownerId"),
4283
+ programId,
4284
+ startDate: today,
4285
+ endDate: str("endDate") ? clampEndToParent(str("endDate"), program.endDate, today) : reckoning ? reckonedEnd(reckoning, today) : clampEndToParent(plus(today, 60), program.endDate, today),
4286
+ budget: typeof v.budget === "number" ? v.budget : 0
4287
+ });
4288
+ if (reckoning) applyReckoning(store.getState(), id, reckoning, today);
4289
+ return id;
4213
4290
  }
4214
- return null;
4215
- };
4216
- const offOn = (personId, day) => {
4217
- if (isWeekend(day)) return null;
4218
- const person = personId ? people.get(personId) : void 0;
4219
- const away = person ? awayDays(person) : null;
4220
- if (away && day >= away[0] && day <= away[1]) return { kind: "away" };
4221
- const h = holidayOn(own(personId), day);
4222
- return h ? {
4223
- kind: "holiday",
4224
- name: h.name,
4225
- country: h.country
4226
- } : null;
4227
- };
4228
- return {
4229
- countries: harbor,
4230
- countriesOf: own,
4231
- offOn,
4232
- works: (personId, day) => !isWeekend(day) && offOn(personId, day) === null,
4233
- active: harbor.length > 0 || data.people.some((p) => countryCode(p.country) || awayDays(p) !== null)
4234
- };
4235
- }
4236
- /** A calendar where nobody has holidays or time away: Monday to Friday. */
4237
- const PLAIN_CALENDAR = workCalendar({ people: [] });
4238
- /**
4239
- * The public holidays anyone in the harbor takes between two days, one
4240
- * entry a day: "Christmas Day (GB, US)". For views that aren't one person's,
4241
- * like the timeline.
4242
- */
4243
- function harborHolidays(data, from, to) {
4244
- const countries = new Set(workCalendar(data).countries);
4245
- for (const p of data.people) {
4246
- const c = countryCode(p.country);
4247
- if (c) countries.add(c);
4248
- }
4249
- const byDay = /* @__PURE__ */ new Map();
4250
- for (const h of holidaysBetween([...countries], from, to)) {
4251
- if (isWeekend(h.date)) continue;
4252
- byDay.set(h.date, [...byDay.get(h.date) ?? [], h]);
4291
+ case "program": {
4292
+ const portfolioId = ctx.portfolioId ?? str("portfolioId");
4293
+ if (!s.portfolios.some((p) => p.id === portfolioId)) return null;
4294
+ return s.addProgram({
4295
+ name: str("name"),
4296
+ description: "",
4297
+ swatch: nextSwatch(s.programs.filter((p) => p.portfolioId === portfolioId).map((p) => p.swatch)),
4298
+ ownerId: str("ownerId"),
4299
+ portfolioId,
4300
+ startDate: today,
4301
+ endDate: str("endDate") || plus(today, 90)
4302
+ });
4303
+ }
4304
+ case "milestone":
4305
+ if (!ctx.programId) return null;
4306
+ return s.addMilestone({
4307
+ programId: ctx.programId,
4308
+ name: str("name"),
4309
+ dueDate: str("dueDate") || plus(today, 14)
4310
+ });
4311
+ case "raid": {
4312
+ const projectId = ctx.projectId ?? (v.projectId || null);
4313
+ const project = projectId ? s.projects.find((p) => p.id === projectId) : null;
4314
+ return s.addRaidItem({
4315
+ kind: v.kind,
4316
+ title: str("title"),
4317
+ projectId,
4318
+ programId: project?.programId ?? null,
4319
+ ownerId: str("ownerId") || null,
4320
+ ...typeof v.probability === "number" ? { probability: v.probability } : {},
4321
+ ...typeof v.impact === "number" ? { impact: v.impact } : {}
4322
+ });
4323
+ }
4324
+ case "spend":
4325
+ if (!ctx.projectId || typeof v.amount !== "number") return null;
4326
+ return s.addSpend(ctx.projectId, {
4327
+ amount: v.amount,
4328
+ date: str("date") || today,
4329
+ note: str("note")
4330
+ });
4253
4331
  }
4254
- return [...byDay].map(([date, hs]) => {
4255
- const names = [...new Set(hs.map((h) => h.name))];
4256
- const codes = hs.map((h) => h.country).join(", ");
4257
- return {
4258
- date,
4259
- names: `${names.join(" / ")} (${codes})`
4260
- };
4261
- });
4262
4332
  }
4263
4333
 
4264
4334
  //#endregion
4265
- //#region src/lib/tide.ts
4266
- init_date_fns();
4267
- init_format();
4268
- /**
4269
- * The tide forecast: when a project will likely finish, from how fast its
4270
- * crew has actually closed tasks. Each of a thousand runs replays the coming
4271
- * weeks by drawing at random from the project's recent weekly counts, until
4272
- * the open tasks run out; the spread of finish days is the forecast. No
4273
- * estimates or story points: only tasks and when they were finished.
4274
- *
4275
- * Seeded from the project and the day, so the same project reads the same
4276
- * on the server and the client and doesn't jitter between renders.
4277
- */
4278
- /** How many weeks back the crew's pace is read from. */
4279
- const TIDE_WEEKS = 6;
4280
- const TRIALS = 1e3;
4281
- /** A run that hasn't finished in two years is counted as not finishing. */
4282
- const MAX_WEEKS = 104;
4283
- /** Fewer finished tasks than this and the forecast says it's rough. */
4284
- const ROUGH_BELOW = 5;
4285
- const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
4286
- /** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
4287
- function weeklyFinished(tasks, startDate, now, weeks = 6) {
4288
- const today = noon$2(todayISO(now));
4289
- const age = Math.floor(differenceInCalendarDays(today, noon$2(startDate)) / 7) + 1;
4290
- const span = Math.max(1, Math.min(weeks, age));
4291
- const counts = new Array(span).fill(0);
4292
- for (const t of tasks) {
4293
- if (t.status !== "done") continue;
4294
- const at = t.completedAt ? noon$2(t.completedAt) : today;
4295
- const back = Math.max(0, differenceInCalendarDays(today, at));
4296
- const week = Math.floor(back / 7);
4297
- if (week < span) counts[span - 1 - week] += 1;
4298
- }
4299
- return counts;
4335
+ //#region src/lib/board.ts
4336
+ /** The column's cap after "No cap" (`uncapped`) or a number is picked. */
4337
+ function withWipLimit(col, limit) {
4338
+ const { uncapped: _drop, ...rest } = col;
4339
+ return limit == null ? {
4340
+ ...rest,
4341
+ wipLimit: null,
4342
+ uncapped: true
4343
+ } : {
4344
+ ...rest,
4345
+ wipLimit: limit
4346
+ };
4300
4347
  }
4301
- function hash(text) {
4302
- let h = 2166136261;
4303
- for (let i = 0; i < text.length; i++) {
4304
- h ^= text.charCodeAt(i);
4305
- h = Math.imul(h, 16777619);
4348
+ function defaultWipFor(status) {
4349
+ if (status === "doing") return 3;
4350
+ if (status === "review") return 2;
4351
+ return null;
4352
+ }
4353
+ function col(status, patch = {}) {
4354
+ return {
4355
+ status,
4356
+ label: TASK_STATUS_LABEL[status],
4357
+ visible: true,
4358
+ wipLimit: null,
4359
+ ...patch
4360
+ };
4361
+ }
4362
+ function columnsFor(preset) {
4363
+ switch (preset) {
4364
+ case "lean": return [
4365
+ col("backlog", { visible: false }),
4366
+ col("todo"),
4367
+ col("doing", { wipLimit: 3 }),
4368
+ col("review", {
4369
+ visible: false,
4370
+ wipLimit: 2
4371
+ }),
4372
+ col("done")
4373
+ ];
4374
+ case "delivery": return [
4375
+ col("backlog", { visible: false }),
4376
+ col("todo"),
4377
+ col("doing", { wipLimit: 3 }),
4378
+ col("review", { wipLimit: 2 }),
4379
+ col("done")
4380
+ ];
4381
+ case "intake": return [
4382
+ col("backlog"),
4383
+ col("todo"),
4384
+ col("doing", {
4385
+ visible: false,
4386
+ wipLimit: 3
4387
+ }),
4388
+ col("review", {
4389
+ visible: false,
4390
+ wipLimit: 2
4391
+ }),
4392
+ col("done")
4393
+ ];
4394
+ default: return [
4395
+ col("backlog"),
4396
+ col("todo"),
4397
+ col("doing", { wipLimit: 3 }),
4398
+ col("review", { wipLimit: 2 }),
4399
+ col("done")
4400
+ ];
4306
4401
  }
4307
- return h >>> 0;
4308
4402
  }
4309
- /** mulberry32: small, fast, and the same everywhere for the same seed. */
4310
- function random(seed) {
4311
- let a = seed;
4312
- return () => {
4313
- a = a + 1831565813 | 0;
4314
- let t = Math.imul(a ^ a >>> 15, 1 | a);
4315
- t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
4316
- return ((t ^ t >>> 14) >>> 0) / 4294967296;
4403
+ function applyPreset(id) {
4404
+ return {
4405
+ preset: id,
4406
+ columns: columnsFor(id).map((c) => ({ ...c })),
4407
+ swimlane: "none",
4408
+ cardFields: { ...ALL_FIELDS },
4409
+ compactCards: id === "intake"
4317
4410
  };
4318
4411
  }
4319
- /** Days from today to finishing, one per run; Infinity for a run that never does. */
4320
- function simulate(remaining, weekly, seed, pace = 1, shares = null) {
4321
- const next = random(seed);
4322
- const out = [];
4323
- for (let run = 0; run < TRIALS; run++) {
4324
- let left = remaining;
4325
- let days = Infinity;
4326
- for (let week = 0; week < MAX_WEEKS; week++) {
4327
- const drawn = weekly[Math.floor(next() * weekly.length)];
4328
- let closed = pace === 1 ? drawn : Math.floor(drawn * pace) + (next() < drawn * pace % 1 ? 1 : 0);
4329
- const share = shares ? shares(week) : 1;
4330
- if (share < 1 && closed > 0) {
4331
- const scaled = closed * share;
4332
- closed = Math.floor(scaled) + (next() < scaled - Math.floor(scaled) ? 1 : 0);
4333
- }
4334
- if (closed >= left) {
4335
- const landed = Array.from({ length: closed }, () => 1 + Math.floor(next() * 7));
4336
- landed.sort((a, b) => a - b);
4337
- days = week * 7 + landed[left - 1];
4338
- break;
4339
- }
4340
- left -= closed;
4341
- }
4342
- out.push(days);
4343
- }
4344
- return out.sort((a, b) => a - b);
4412
+ function defaultBoardConfig() {
4413
+ return applyPreset("trail");
4345
4414
  }
4346
- /**
4347
- * Per run, the fewest days the sounded tasks allow: each draws one of its
4348
- * answers at random, so a wide spread widens the forecast. A task already
4349
- * in play counts half its days; it's partly done.
4350
- */
4351
- function soundingFloors(tasks, reflections, base, seed, calendar) {
4352
- const draws = [];
4353
- for (const t of tasks) {
4354
- if (t.status === "done") continue;
4355
- const days = soundingDraws(t, reflections);
4356
- if (days.length === 0) continue;
4357
- draws.push({
4358
- days,
4359
- share: t.status === "doing" || t.status === "review" ? .5 : 1,
4360
- who: t.assigneeId
4415
+ function clampWip(value) {
4416
+ if (value == null || value === "") return null;
4417
+ const n = typeof value === "number" ? value : Number(value);
4418
+ if (!Number.isFinite(n) || n <= 0) return null;
4419
+ return Math.min(20, Math.round(n));
4420
+ }
4421
+ function capOf(c) {
4422
+ const n = clampWip(c.wipLimit);
4423
+ if (n != null) return { wipLimit: n };
4424
+ if (c.uncapped === true) return {
4425
+ wipLimit: null,
4426
+ uncapped: true
4427
+ };
4428
+ return { wipLimit: defaultWipFor(c.status) };
4429
+ }
4430
+ function normalizeBoardConfig(raw) {
4431
+ const base = defaultBoardConfig();
4432
+ if (!raw) return base;
4433
+ const byStatus = /* @__PURE__ */ new Map();
4434
+ for (const c of base.columns) byStatus.set(c.status, { ...c });
4435
+ const order = [];
4436
+ if (Array.isArray(raw.columns)) for (const c of raw.columns) {
4437
+ if (!c || !TASK_COLUMNS.includes(c.status)) continue;
4438
+ if (!order.includes(c.status)) order.push(c.status);
4439
+ byStatus.set(c.status, {
4440
+ status: c.status,
4441
+ label: typeof c.label === "string" && c.label.trim() ? c.label.trim() : TASK_STATUS_LABEL[c.status],
4442
+ visible: c.visible !== false,
4443
+ ...capOf(c)
4361
4444
  });
4362
4445
  }
4363
- if (draws.length === 0) return {
4364
- floors: null,
4365
- sounded: 0
4366
- };
4367
- const next = random(seed);
4368
- const memo = /* @__PURE__ */ new Map();
4369
- const ahead = (d, who) => {
4370
- const k = `${who}|${d}`;
4371
- let v = memo.get(k);
4372
- if (v === void 0) {
4373
- const works = calendar?.active ? (at) => calendar.works(who, todayISO(at)) : void 0;
4374
- memo.set(k, v = workingDaysAhead(d, base, works));
4375
- }
4376
- return v;
4377
- };
4378
- const floors = [];
4379
- for (let run = 0; run < TRIALS; run++) {
4380
- let most = 0;
4381
- for (const x of draws) {
4382
- const d = x.days[Math.floor(next() * x.days.length)] * x.share;
4383
- most = Math.max(most, ahead(d, x.who));
4384
- }
4385
- floors.push(most);
4446
+ for (const status of TASK_COLUMNS) if (!order.includes(status)) order.push(status);
4447
+ const columns = order.map((status) => byStatus.get(status));
4448
+ if (!columns.some((c) => c.visible)) {
4449
+ const todo = columns.find((c) => c.status === "todo") ?? columns[0];
4450
+ todo.visible = true;
4386
4451
  }
4452
+ const swimlane = raw.swimlane === "assignee" || raw.swimlane === "priority" ? raw.swimlane : "none";
4453
+ const cardFields = {
4454
+ dueDate: raw.cardFields?.dueDate !== false,
4455
+ assignee: raw.cardFields?.assignee !== false,
4456
+ priority: raw.cardFields?.priority !== false
4457
+ };
4387
4458
  return {
4388
- floors,
4389
- sounded: draws.length
4459
+ preset: raw.preset === "lean" || raw.preset === "delivery" || raw.preset === "intake" || raw.preset === "trail" || raw.preset === "custom" ? raw.preset : "custom",
4460
+ columns,
4461
+ swimlane,
4462
+ cardFields,
4463
+ compactCards: Boolean(raw.compactCards)
4390
4464
  };
4391
4465
  }
4466
+ function columnLabel(colCfg) {
4467
+ return colCfg.label.trim() || TASK_STATUS_LABEL[colCfg.status];
4468
+ }
4469
+ function visibleColumns(config) {
4470
+ return config.columns.filter((c) => c.visible);
4471
+ }
4392
4472
  /**
4393
- * Each coming week's share of the crew's usual working days, from day 1
4394
- * (tomorrow): who owns the open tasks, less their holidays and time away.
4395
- * Null when the calendar has nothing in it, so the forecast reads as before.
4473
+ * A board's caps by status, for agents and the terminal: every column, hidden
4474
+ * ones too (a cap can be set on them, and they come back with it), null when
4475
+ * uncapped. `hiddenStatuses` says which aren't on the board.
4396
4476
  */
4397
- function weekShares(tasks, base, calendar) {
4398
- const crew = [...new Set(tasks.filter((t) => t.status !== "done").map((t) => t.assigneeId))].filter((id) => id !== null);
4399
- const people = crew.length ? crew : [null];
4400
- if (!calendar?.active) return {
4401
- shares: null,
4402
- crew: people
4403
- };
4404
- const known = [];
4405
- const share = (week) => {
4406
- const had = known[week];
4407
- if (had !== void 0) return had;
4408
- let usual = 0;
4409
- let working = 0;
4410
- for (let d = week * 7 + 1; d <= week * 7 + 7; d++) {
4411
- const day = todayISO(addDays(base, d));
4412
- const wd = addDays(base, d).getDay();
4413
- if (wd === 0 || wd === 6) continue;
4414
- for (const p of people) {
4415
- usual++;
4416
- if (calendar.works(p, day)) working++;
4417
- }
4418
- }
4419
- return known[week] = usual ? working / usual : 1;
4420
- };
4477
+ function columnLimits(config) {
4478
+ const out = {};
4479
+ for (const c of config.columns) out[c.status] = c.wipLimit;
4480
+ return out;
4481
+ }
4482
+ function hiddenStatuses(config) {
4483
+ return hiddenColumns(config).map((c) => c.status);
4484
+ }
4485
+ function hiddenColumns(config) {
4486
+ return config.columns.filter((c) => !c.visible);
4487
+ }
4488
+ function boardConfigOf(data, projectId) {
4489
+ return normalizeBoardConfig(data.boardConfigs?.[projectId]);
4490
+ }
4491
+ function applyDefaultWipToAll(configs) {
4492
+ const next = {};
4493
+ for (const [id, raw] of Object.entries(configs ?? {})) next[id] = normalizeBoardConfig(raw);
4494
+ return next;
4495
+ }
4496
+ function wipOverflow(col, currentCount, extra = 1) {
4497
+ if (col.wipLimit == null || extra <= 0) return null;
4498
+ const next = currentCount + extra;
4499
+ if (next <= col.wipLimit) return null;
4421
4500
  return {
4422
- shares: share,
4423
- crew: people
4501
+ label: columnLabel(col),
4502
+ limit: col.wipLimit,
4503
+ current: currentCount,
4504
+ next
4424
4505
  };
4425
4506
  }
4426
- /** The holidays and away days the crew has between tomorrow and `until`. */
4427
- function ashoreUntil(calendar, crew, base, until) {
4428
- const holidays = [];
4429
- let awayDays = 0;
4430
- if (!calendar?.active || !Number.isFinite(until)) return {
4431
- holidays,
4432
- awayDays
4433
- };
4434
- const seen = /* @__PURE__ */ new Set();
4435
- for (let d = 1; d <= until; d++) {
4436
- const day = todayISO(addDays(base, d));
4437
- for (const p of crew) {
4438
- const off = calendar.offOn(p, day);
4439
- if (off?.kind === "away") awayDays++;
4440
- else if (off?.kind === "holiday" && !seen.has(`${day}|${off.country}`)) {
4441
- seen.add(`${day}|${off.country}`);
4442
- holidays.push({
4443
- date: day,
4444
- name: off.name,
4445
- country: off.country
4446
- });
4447
- }
4507
+ function wipFullMessage(overflow) {
4508
+ return `${overflow.label === "In play" ? "In-play" : overflow.label} is full (${overflow.limit}). Finish one or raise the cap.`;
4509
+ }
4510
+ function columnWip(data, projectId, status, extra = 1) {
4511
+ const col = boardConfigOf(data, projectId).columns.find((c) => c.status === status);
4512
+ if (!col) return null;
4513
+ const current = tasksOf(data.tasks, projectId).filter((t) => t.status === status).length;
4514
+ return wipOverflow(col, current, extra);
4515
+ }
4516
+ var BOARD_PRESETS, ALL_FIELDS, DEFAULT_DOING_WIP, DEFAULT_REVIEW_WIP, WIP_CAP_CHOICES;
4517
+ var init_board = __esmMin((() => {
4518
+ init_indexes();
4519
+ init_swatches();
4520
+ BOARD_PRESETS = [
4521
+ {
4522
+ id: "trail",
4523
+ name: "Trail",
4524
+ kicker: "The full walk",
4525
+ blurb: "Backlog through done. Three in play, two in review."
4526
+ },
4527
+ {
4528
+ id: "lean",
4529
+ name: "Lean",
4530
+ kicker: "Just the work",
4531
+ blurb: "To do, in play, done. A cap of three in play."
4532
+ },
4533
+ {
4534
+ id: "delivery",
4535
+ name: "Delivery",
4536
+ kicker: "A review gate",
4537
+ blurb: "In-play and review are capped so work actually finishes."
4538
+ },
4539
+ {
4540
+ id: "intake",
4541
+ name: "Intake",
4542
+ kicker: "Collect, then ship",
4543
+ blurb: "Backlog, to do, done. For paused or sourcing work."
4448
4544
  }
4449
- }
4450
- return {
4451
- holidays,
4452
- awayDays
4545
+ ];
4546
+ ALL_FIELDS = {
4547
+ dueDate: true,
4548
+ assignee: true,
4549
+ priority: true
4453
4550
  };
4551
+ DEFAULT_DOING_WIP = 3;
4552
+ DEFAULT_REVIEW_WIP = 2;
4553
+ WIP_CAP_CHOICES = [
4554
+ 1,
4555
+ 2,
4556
+ 3,
4557
+ 4,
4558
+ 5,
4559
+ 8,
4560
+ 10,
4561
+ 15
4562
+ ];
4563
+ }));
4564
+
4565
+ //#endregion
4566
+ //#region src/lib/keep.ts
4567
+ init_board();
4568
+ /** Whole days left in the trial, rounded up: 30 on the first day, 1 on the last. 0 once it's over. */
4569
+ function trialDaysLeft(trialEndsAt, now = /* @__PURE__ */ new Date()) {
4570
+ if (!trialEndsAt) return 0;
4571
+ const ms = Date.parse(trialEndsAt) - now.getTime();
4572
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
4573
+ return Math.ceil(ms / 864e5);
4454
4574
  }
4455
- /** What the forecast reads beyond the tasks: soundings and the work calendar. */
4456
- function tideOptions(data) {
4575
+
4576
+ //#endregion
4577
+ //#region src/lib/cascade.ts
4578
+ /** What deleting a portfolio, program, or project takes with it, and the data left behind. Pure. */
4579
+ function cascadeDelete(data, kind, id) {
4580
+ const portfolioIds = new Set(kind === "portfolio" ? [id] : []);
4581
+ const programIds = new Set(kind === "program" ? [id] : data.programs.filter((p) => portfolioIds.has(p.portfolioId)).map((p) => p.id));
4582
+ const projectIds = new Set(kind === "project" ? [id] : data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id));
4583
+ const taskIds = new Set(data.tasks.filter((t) => projectIds.has(t.projectId)).map((t) => t.id));
4584
+ const milestones = data.milestones.filter((m) => !programIds.has(m.programId)).map((m) => withoutBlockers(m, projectIds));
4585
+ const constraints = (data.constraints ?? []).filter((c) => !programIds.has(c.programId));
4586
+ const raidItems = data.raidItems.filter((r) => !(r.portfolioId && portfolioIds.has(r.portfolioId)) && !(r.programId && programIds.has(r.programId)) && !(r.projectId && projectIds.has(r.projectId)));
4587
+ const boardConfigs = Object.fromEntries(Object.entries(data.boardConfigs ?? {}).filter(([k]) => !projectIds.has(k)));
4457
4588
  return {
4458
- reflections: data.reflections,
4459
- calendar: workCalendar({
4460
- people: data.people ?? [],
4461
- workCalendar: data.workCalendar
4462
- })
4589
+ data: {
4590
+ portfolios: data.portfolios.filter((p) => !portfolioIds.has(p.id)),
4591
+ programs: data.programs.filter((p) => !programIds.has(p.id)).map((p) => withoutBlockers(p, programIds)),
4592
+ projects: data.projects.filter((p) => !projectIds.has(p.id)).map((p) => withoutBlockers(p, projectIds)),
4593
+ tasks: data.tasks.filter((t) => !taskIds.has(t.id)).map((t) => withoutBlockers(t, taskIds)),
4594
+ milestones,
4595
+ constraints,
4596
+ raidItems,
4597
+ comments: data.comments.filter((c) => !taskIds.has(c.taskId)),
4598
+ attachments: (data.attachments ?? []).filter((a) => !taskIds.has(a.taskId)),
4599
+ boardConfigs,
4600
+ ropes: (data.ropes ?? []).map((r) => ({
4601
+ ...r,
4602
+ taskIds: r.taskIds.filter((t) => !taskIds.has(t))
4603
+ })),
4604
+ spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId)),
4605
+ reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId))
4606
+ },
4607
+ counts: {
4608
+ programs: programIds.size,
4609
+ projects: projectIds.size,
4610
+ tasks: taskIds.size,
4611
+ milestones: data.milestones.length - milestones.length,
4612
+ raidItems: data.raidItems.length - raidItems.length,
4613
+ constraints: (data.constraints ?? []).length - constraints.length
4614
+ }
4463
4615
  };
4464
4616
  }
4617
+ /** "2 projects, 14 tasks, 3 milestones and 2 risks or issues" — or "" when nothing else goes. */
4618
+ function cascadeSummary(counts, kind) {
4619
+ const bits = [];
4620
+ const n = (count, one, many) => count > 0 ? `${count} ${count === 1 ? one : many}` : null;
4621
+ if (kind === "portfolio") bits.push(n(counts.programs, "program", "programs") ?? "");
4622
+ if (kind !== "project") bits.push(n(counts.projects, "project", "projects") ?? "");
4623
+ bits.push(n(counts.tasks, "task", "tasks") ?? "");
4624
+ bits.push(n(counts.milestones, "milestone", "milestones") ?? "");
4625
+ bits.push(n(counts.raidItems, "logged risk, issue or decision", "logged risks, issues and decisions") ?? "");
4626
+ const list = bits.filter(Boolean);
4627
+ if (list.length === 0) return "";
4628
+ if (list.length === 1) return list[0];
4629
+ return `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`;
4630
+ }
4631
+ var init_cascade = __esmMin((() => {
4632
+ init_blockers();
4633
+ }));
4634
+
4635
+ //#endregion
4636
+ //#region src/lib/forecast.ts
4637
+ init_cascade();
4638
+ init_date_fns();
4639
+ const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4465
4640
  /**
4466
- * `pace` scales the crew's weekly counts (1 is as they are), for dry dock's
4467
- * "what if Sam joined" (src/lib/dry-dock.ts).
4641
+ * A straight-line estimate at completion from the burn so far: spend divided
4642
+ * by the share of the schedule elapsed. Null before anything is spent or
4643
+ * before the schedule starts, since there is nothing to project yet. After
4644
+ * the end date the estimate is the spend itself.
4468
4645
  */
4469
- function tideForecast(project, tasks, now, opts = {}) {
4470
- const remaining = tasks.filter((t) => t.status !== "done").length;
4471
- if (remaining === 0) return { kind: "done" };
4472
- if (project.health === "paused") return {
4473
- kind: "paused",
4474
- remaining
4475
- };
4476
- const weekly = weeklyFinished(tasks, project.startDate, now);
4477
- const finished = weekly.reduce((a, b) => a + b, 0);
4478
- if (finished === 0) return {
4479
- kind: "no-pace",
4480
- remaining
4481
- };
4482
- const today = todayISO(now);
4483
- const base = noon$2(today);
4484
- const { shares, crew } = weekShares(tasks, base, opts.calendar);
4485
- const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1), shares);
4486
- const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
4487
- let lastDue = null;
4488
- for (const t of tasks) {
4489
- if (t.status === "done" || !t.dueDate) continue;
4490
- if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
4491
- }
4492
- const floor = lastDue ? differenceInCalendarDays(noon$2(lastDue.dueDate), base) : 0;
4493
- const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`), opts.calendar);
4494
- const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
4495
- const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
4496
- const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
4497
- const toEnd = differenceInCalendarDays(noon$2(project.endDate), base);
4498
- const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
4499
- const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
4500
- const curve = [];
4501
- const step = Math.max(1, Math.ceil(last / 60));
4502
- let i = 0;
4503
- for (let d = 0; d <= last; d += step) {
4504
- while (i < runs.length && runs[i] <= d) i++;
4505
- curve.push({
4506
- date: dayOf(d),
4507
- chance: i / runs.length
4508
- });
4509
- }
4646
+ function forecastAtCompletion(item, now) {
4647
+ if (item.spent <= 0) return null;
4648
+ const total = differenceInCalendarDays(day$5(item.endDate), day$5(item.startDate)) + 1;
4649
+ const gone = differenceInCalendarDays(now, day$5(item.startDate)) + 1;
4650
+ if (total <= 0 || gone <= 0) return null;
4651
+ const elapsed = Math.min(1, gone / total);
4652
+ const eac = Math.round(item.spent / elapsed);
4510
4653
  return {
4511
- kind: "forecast",
4512
- remaining,
4513
- finished,
4514
- weeks: weekly.length,
4515
- perWeek: Math.round(finished / weekly.length * Math.max(0, opts.pace ?? 1) * 10) / 10,
4516
- likely: dayOf(at(.5)),
4517
- veryLikely: dayOf(at(.85)),
4518
- almostCertain: dayOf(at(.95)),
4519
- chanceByEnd: Math.round(byEnd * 100) / 100,
4520
- curve,
4521
- rough: finished < ROUGH_BELOW,
4522
- heldBy,
4523
- sounded: sounded.sounded,
4524
- ashore: ashoreUntil(opts.calendar, crew, base, at(.85))
4654
+ eac,
4655
+ variance: item.budget - eac,
4656
+ elapsed,
4657
+ tone: item.budget <= 0 ? "over" : eac > item.budget * 1.05 ? "over" : eac > item.budget * .95 ? "tight" : "under"
4525
4658
  };
4526
4659
  }
4527
- /** "15%", with "under 5%" and "over 95%" at the ends, where a thousand runs can't be that sure. */
4528
- function chanceText(chance) {
4529
- if (chance < .05) return "under 5%";
4530
- if (chance > .95) return "over 95%";
4531
- return `${Math.round(chance * 20) * 5}%`;
4532
- }
4533
- /**
4534
- * The open task whose due date sets the finish, when the crew's pace alone
4535
- * would be done sooner; null otherwise.
4536
- */
4537
- function dueAfterFinish(tide) {
4538
- return tide.kind === "forecast" ? tide.heldBy : null;
4539
- }
4540
- /** Why there's no forecast, for the states without one. */
4541
- function tideQuiet(tide) {
4542
- switch (tide.kind) {
4543
- case "done": return "Every task is done.";
4544
- case "paused": return "Paused, so its pace says nothing about when it ends.";
4545
- case "no-pace": return `Nothing finished in the last ${6} weeks, so there's no pace to forecast from yet.`;
4546
- default: return null;
4547
- }
4660
+ /** Sum of the children's estimates, using spend where a child has no forecast yet. */
4661
+ function forecastRollup(items, budget, now) {
4662
+ const eac = items.map((i) => forecastAtCompletion(i, now)?.eac ?? i.spent).reduce((a, b) => a + b, 0);
4663
+ if (eac <= 0) return null;
4664
+ const elapsed = Math.max(0, ...items.map((i) => forecastAtCompletion(i, now)?.elapsed ?? 0));
4665
+ return {
4666
+ eac,
4667
+ variance: budget - eac,
4668
+ elapsed,
4669
+ tone: budget <= 0 ? "over" : eac > budget * 1.05 ? "over" : eac > budget * .95 ? "tight" : "under"
4670
+ };
4548
4671
  }
4549
4672
 
4550
4673
  //#endregion
@@ -4792,12 +4915,17 @@ function slipDays(p) {
4792
4915
  if (!p.baselineEnd) return null;
4793
4916
  return differenceInCalendarDays(day$3(p.endDate), day$3(p.baselineEnd));
4794
4917
  }
4795
- /** The projects with the current dates committed as their baseline; ones that already have one are left alone unless `reset`. */
4796
- function withBaseline(projects, reset = false) {
4918
+ /**
4919
+ * The projects with the current dates committed as their baseline; ones that
4920
+ * already have one are left alone unless `reset`. `at` is when (ISO), so work
4921
+ * added later reads as growth.
4922
+ */
4923
+ function withBaseline(projects, reset = false, at) {
4797
4924
  return projects.map((p) => hasBaseline(p) && !reset ? p : {
4798
4925
  ...p,
4799
4926
  baselineStart: p.startDate,
4800
- baselineEnd: p.endDate
4927
+ baselineEnd: p.endDate,
4928
+ ...at ? { baselineAt: at } : {}
4801
4929
  });
4802
4930
  }
4803
4931
  /** A caption for the timeline: "slipped 12 days", "12 days early", or null when on the baseline. */
@@ -5207,6 +5335,7 @@ init_date_fns();
5207
5335
  init_format();
5208
5336
  init_indexes();
5209
5337
  init_spend();
5338
+ init_tide();
5210
5339
  /**
5211
5340
  * The cost of a slip: what each week past a project's end date costs, from
5212
5341
  * how fast its spend ledger has been burning, set against the tide forecast's
@@ -5332,6 +5461,7 @@ var init_work = __esmMin((() => {
5332
5461
  init_date_fns();
5333
5462
  init_date_text();
5334
5463
  init_indexes();
5464
+ init_tide();
5335
5465
  init_format();
5336
5466
  init_raid();
5337
5467
  init_spend();
@@ -6906,6 +7036,16 @@ var init_decisions = __esmMin((() => {
6906
7036
 
6907
7037
  //#endregion
6908
7038
  //#region src/lib/brief.ts
7039
+ /**
7040
+ * Open projects whose plan has grown enough since the baseline to mention
7041
+ * (barnacles.ts), most grown first.
7042
+ */
7043
+ function barnacleLinesOf(data, projects, now) {
7044
+ return projects.filter((p) => p.health !== "done").map((project) => ({
7045
+ project,
7046
+ barnacles: barnaclesOf(project, tasksOf(data.tasks, project.id), data.people, now, Math.max(0, slipDays(project) ?? 0) || null)
7047
+ })).filter((l) => briefWorthy(l.barnacles)).sort((a, b) => (b.barnacles.share ?? 0) - (a.barnacles.share ?? 0)).slice(0, 5);
7048
+ }
6909
7049
  /** Late tasks worst first, each with what it holds up, so the letter says what to chase. */
6910
7050
  function lateLinesOf(data, tasks, now) {
6911
7051
  const waiters = waitersByBlocker(tasks);
@@ -7013,6 +7153,11 @@ function composeBriefBody(opts) {
7013
7153
  for (const d of opts.revisit) lines.push(`• ${d.title}${d.where ? ` (${d.where})` : ""}: ${d.reason}`);
7014
7154
  lines.push("");
7015
7155
  }
7156
+ if (opts.barnacles?.length) {
7157
+ lines.push("Barnacles (work added since the baseline)");
7158
+ for (const b of opts.barnacles) lines.push(`• ${b.project}: ${b.line}`);
7159
+ lines.push("");
7160
+ }
7016
7161
  const done = () => {
7017
7162
  if (!opts.shipped?.length) return;
7018
7163
  lines.push("Done this week");
@@ -7262,6 +7407,7 @@ function briefOf(data, opts = {}) {
7262
7407
  projectIds,
7263
7408
  portfolioId
7264
7409
  }), todayISO(now)),
7410
+ barnacles: barnacleLinesOf(data, projects, now),
7265
7411
  talk,
7266
7412
  milestonesSoon,
7267
7413
  milestonesLate,
@@ -7318,6 +7464,10 @@ function composeBriefBodyFrom(brief, data, workspace, all = false) {
7318
7464
  title: d.item.title,
7319
7465
  reason: d.reason,
7320
7466
  where: d.where
7467
+ })),
7468
+ barnacles: brief.barnacles.map((b) => ({
7469
+ project: b.project.name,
7470
+ line: barnaclesShort(b.barnacles)
7321
7471
  }))
7322
7472
  });
7323
7473
  }
@@ -7329,7 +7479,7 @@ function briefHidden(brief) {
7329
7479
  const over = (n) => Math.max(0, n - 8);
7330
7480
  return over(brief.late.length) + over(brief.finished.length);
7331
7481
  }
7332
- var BRIEF_QUIET, BRIEF_LATE_SHOWN;
7482
+ var BRIEF_BARNACLES_SHOWN, BRIEF_QUIET, BRIEF_LATE_SHOWN;
7333
7483
  var init_brief = __esmMin((() => {
7334
7484
  init_date_fns();
7335
7485
  init_date_text();
@@ -7343,6 +7493,9 @@ var init_brief = __esmMin((() => {
7343
7493
  init_clock();
7344
7494
  init_bottle();
7345
7495
  init_decisions();
7496
+ init_barnacles();
7497
+ init_baseline();
7498
+ BRIEF_BARNACLES_SHOWN = 5;
7346
7499
  BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
7347
7500
  BRIEF_LATE_SHOWN = 8;
7348
7501
  }));
@@ -12591,6 +12744,7 @@ init_format();
12591
12744
  init_indexes();
12592
12745
  init_load();
12593
12746
  init_task_keys();
12747
+ init_tide();
12594
12748
  const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
12595
12749
  const MAX_ADD = 200;
12596
12750
  /**
@@ -18249,7 +18403,8 @@ function chainOf(line, walk, target, today) {
18249
18403
  links,
18250
18404
  finish,
18251
18405
  target,
18252
- over: target ? days$1(target, finish) : null
18406
+ over: target ? days$1(target, finish) : null,
18407
+ undated: 0
18253
18408
  };
18254
18409
  }
18255
18410
  /** What sets a project's date: the line behind its open task that finishes last. */
@@ -18274,7 +18429,11 @@ function projectPole(project, all, now) {
18274
18429
  kind: "none",
18275
18430
  reason: "None of its open tasks has a due date, so there's no date to trace."
18276
18431
  };
18277
- return chainOf(lineTo(last, all, walk), walk, project.endDate.slice(0, 10), today);
18432
+ const undated = mine.filter((t) => !walk(t).finish).length;
18433
+ return {
18434
+ ...chainOf(lineTo(last, all, walk), walk, project.endDate.slice(0, 10), today),
18435
+ undated
18436
+ };
18278
18437
  }
18279
18438
  /** What holds one task up: the line of open tasks it waits on, ending at it. */
18280
18439
  function taskPole(task, all, now) {
@@ -18340,7 +18499,8 @@ function milestonePole(milestone, data, now) {
18340
18499
  project: first.project,
18341
18500
  finish: first.finish
18342
18501
  },
18343
- others
18502
+ others,
18503
+ undated: first.pole.kind === "chain" ? first.pole.undated : 0
18344
18504
  };
18345
18505
  }
18346
18506
  const plural$2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
@@ -18365,6 +18525,12 @@ function poleHeadline(pole, name, held) {
18365
18525
  if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
18366
18526
  return `${lead}, ${plural$2(-pole.over, "day")} before ${held}.${tail}`;
18367
18527
  }
18528
+ /** "13 open tasks have no due date, so they can't hold the date."; null when none. */
18529
+ function undatedText(pole) {
18530
+ if (pole.kind !== "chain" || pole.undated === 0) return null;
18531
+ const n = pole.undated;
18532
+ return n === 1 ? "1 open task has no due date, so it can't hold the date." : `${n} open tasks have no due date, so they can't hold the date.`;
18533
+ }
18368
18534
 
18369
18535
  //#endregion
18370
18536
  //#region src/lib/csv.ts
@@ -18758,6 +18924,8 @@ var init_passage = __esmMin((() => {
18758
18924
  init_task_keys();
18759
18925
  init_brief();
18760
18926
  init_indexes();
18927
+ init_tide();
18928
+ init_barnacles();
18761
18929
  init_spend();
18762
18930
  init_hill();
18763
18931
  init_raid();
@@ -18775,6 +18943,7 @@ init_work();
18775
18943
  init_fog();
18776
18944
  init_passage();
18777
18945
  init_board();
18946
+ init_holidays();
18778
18947
  init_soundings();
18779
18948
  /**
18780
18949
  * Where the website opens one task or project, switching to its harbor
@@ -18797,6 +18966,30 @@ function publicRoadmap(ctx, projectId) {
18797
18966
  ...ctx.origin ? { url: `${ctx.origin}/roadmap/${token}` } : {}
18798
18967
  };
18799
18968
  }
18969
+ /** A project's scope growth (src/lib/barnacles.ts), for an agent: numbers and the sentences the website shows. */
18970
+ function barnaclesData(b) {
18971
+ return {
18972
+ measuredFrom: b.from === "baseline" ? "when the baseline was set" : "a week after its first task was made",
18973
+ since: b.since,
18974
+ planned: b.planned,
18975
+ added: b.added,
18976
+ addedPercent: b.share == null ? null : Math.round(b.share * 100),
18977
+ addedThisWeek: b.thisWeek,
18978
+ headline: barnaclesHeadline(b),
18979
+ tasksPerWeek: b.perWeek,
18980
+ daysOfWork: b.days,
18981
+ slipDays: b.slip,
18982
+ daysLine: barnaclesDaysLine(b),
18983
+ byWeek: b.weeks.filter((w) => w.added > 0).map((w) => ({
18984
+ weekOf: w.week,
18985
+ added: w.added
18986
+ })),
18987
+ byWho: b.adders.map((a) => ({
18988
+ who: a.personId ? a.name : null,
18989
+ added: a.added
18990
+ }))
18991
+ };
18992
+ }
18800
18993
  /** A question the tool can't answer as asked; the agent sees the message. */
18801
18994
  var ToolError = class extends Error {};
18802
18995
  /**
@@ -19553,7 +19746,7 @@ const HARBOR_TOOLS = [
19553
19746
  tool({
19554
19747
  name: "weekly_brief",
19555
19748
  title: "Weekly brief",
19556
- description: "The weekly brief as the website writes it: programs off track and at risk, risks and issues to watch, milestones, late work and what it holds up, what got done this week and what the crew said. Plain text, ready to send." + DATA_NOTE,
19749
+ description: "The weekly brief as the website writes it: programs off track and at risk, risks and issues to watch, milestones, late work and what it holds up, projects whose plan grew a lot since the baseline (barnacles), what got done this week and what the crew said. Plain text, ready to send." + DATA_NOTE,
19557
19750
  input: object({
19558
19751
  harbor: harborArg,
19559
19752
  portfolio: string().max(200).optional().describe("Portfolio id or name; all when left out."),
@@ -19827,6 +20020,7 @@ const HARBOR_TOOLS = [
19827
20020
  ...d.rationale?.trim() ? { why: d.rationale.trim() } : {},
19828
20021
  ...d.status === "superseded" ? { changedLater: true } : {}
19829
20022
  })),
20023
+ barnacles: barnaclesData(log.barnacles),
19830
20024
  closedBy: log.carriers.map((c) => ({
19831
20025
  who: c.person.name,
19832
20026
  tasks: c.closed
@@ -19879,7 +20073,7 @@ const HARBOR_TOOLS = [
19879
20073
  run: (ctx, input) => {
19880
20074
  const { data } = ctx;
19881
20075
  const today = todayISO(ctx.now);
19882
- const until = plus(today, input.days);
20076
+ const until = plus$1(today, input.days);
19883
20077
  return {
19884
20078
  countries: workCalendar(data).countries.map((c) => ({
19885
20079
  code: c,
@@ -19905,7 +20099,7 @@ const HARBOR_TOOLS = [
19905
20099
  tool({
19906
20100
  name: "long_pole",
19907
20101
  title: "What's holding this up",
19908
- description: "The long pole behind a project, a task or a milestone: of all the task waits behind it, the one line that sets its date, first blocker to last. Each link has its due date, the soonest it can finish (its due date, later when what it waits on finishes after, today at the earliest for late work) and slack: days it can slip before it moves the next link, or the end; negative when it already does. over is how many days the line finishes past the date held (a project's end, a task's or milestone's due date). For a milestone, via is the project it waits on that finishes last, and others are the rest with their slack." + DATA_NOTE,
20102
+ description: "The long pole behind a project, a task or a milestone: of all the task waits behind it, the one line that sets its date, first blocker to last. Each link has its due date, the soonest it can finish (its due date, later when what it waits on finishes after, today at the earliest for late work) and slack: days it can slip before it moves the next link, or the end; negative when it already does. daysSpare is how many days the line finishes before the date held (endDate for a project, dueDate for a task or milestone); negative is that many days over. undated counts open tasks left out for having no due date (note says so). For a milestone, via is the project it waits on that finishes last, and others are the rest with their slack." + DATA_NOTE,
19909
20103
  input: object({
19910
20104
  harbor: harborArg,
19911
20105
  of: string().min(1).max(200).describe("A project (id, name or key like \"RR\"), a task key (\"RR-42\") or a milestone (id or name).")
@@ -19931,8 +20125,10 @@ const HARBOR_TOOLS = [
19931
20125
  },
19932
20126
  headline: poleHeadline(pole, name, held),
19933
20127
  finish: pole.finish,
19934
- held: pole.target,
19935
- over: pole.over,
20128
+ [kind === "project" ? "endDate" : "dueDate"]: pole.target,
20129
+ daysSpare: pole.over == null ? null : -pole.over,
20130
+ undated: pole.undated,
20131
+ ...undatedText(pole) ? { note: undatedText(pole) } : {},
19936
20132
  ...pole.via ? { via: {
19937
20133
  project: pole.via.project.name,
19938
20134
  finish: pole.via.finish
@@ -19968,7 +20164,12 @@ const HARBOR_TOOLS = [
19968
20164
  try {
19969
20165
  milestone = findOne(milestones.length ? milestones : data.milestones, input.of, "milestone");
19970
20166
  } catch (inner) {
19971
- if (inner instanceof ToolError && /^No milestone matches/.test(inner.message)) throw new ToolError(`No project, task key or open milestone matches "${input.of.trim()}".`);
20167
+ if (inner instanceof ToolError && /^No milestone matches/.test(inner.message)) {
20168
+ const projects = data.projects.filter((x) => x.health !== "done");
20169
+ const there = [...projects.slice(0, 12).map((x) => x.key ? `${x.name} (${x.key})` : x.name), ...projects.length > 12 ? [`and ${projects.length - 12} more`] : []];
20170
+ const marks = milestones.slice(0, 6).map((m) => m.name);
20171
+ throw new ToolError(`No project, task key or open milestone matches "${input.of.trim()}".` + (there.length ? ` Open projects: ${there.join(", ")}.` : "") + (marks.length ? ` Open milestones: ${marks.join(", ")}.` : ""));
20172
+ }
19972
20173
  throw inner;
19973
20174
  }
19974
20175
  return answer("milestone", milestone.name, milestonePole(milestone, data, ctx.now), "the milestone");
@@ -19976,6 +20177,26 @@ const HARBOR_TOOLS = [
19976
20177
  return answer("project", project.name, projectPole(project, data.tasks, ctx.now), "the end date");
19977
20178
  }
19978
20179
  }),
20180
+ tool({
20181
+ name: "scope_growth",
20182
+ title: "Barnacles: work added since the plan was set",
20183
+ description: "How much a project's plan has grown since it was set (barnacles): tasks made after the baseline was set (or, without a baseline time, after the project's first week of tasks), as a count and a percent of what was planned, by week (weekOf is a Monday) and by who added them (who is null for tasks made before Harbor kept track, or by a form). daysOfWork puts them at the crew's pace (tasks finished a week over the last six); slipDays is how late the project runs against its planned end (the likely finish at that pace, or the end date's move from the baseline), and daysLine says how much of it the added work explains." + DATA_NOTE,
20184
+ input: object({
20185
+ harbor: harborArg,
20186
+ project: string().min(1).max(200).describe("Project id, name or key.")
20187
+ }),
20188
+ run: (ctx, input) => {
20189
+ const { data } = ctx;
20190
+ const p = findOne(data.projects, input.project, "project");
20191
+ const tasks = tasksOf(data.tasks, p.id);
20192
+ const b = barnaclesOf(p, tasks, data.people, ctx.now, slipAgainstPlan(p, tasks, ctx.now));
20193
+ return {
20194
+ project: p.name,
20195
+ ...p.key ? { key: p.key } : {},
20196
+ ...barnaclesData(b)
20197
+ };
20198
+ }
20199
+ }),
19979
20200
  tool({
19980
20201
  name: "shipping_forecast",
19981
20202
  title: "The harbor forecast",
@@ -21267,7 +21488,7 @@ function hierarchySlice({ get, write }) {
21267
21488
  },
21268
21489
  snapshotBaseline: (programId, reset = false) => {
21269
21490
  const inProgram = new Set(get().projects.filter((p) => p.programId === programId).map((p) => p.id));
21270
- const stamped = withBaseline(get().projects.filter((p) => inProgram.has(p.id)), reset);
21491
+ const stamped = withBaseline(get().projects.filter((p) => inProgram.has(p.id)), reset, stamp());
21271
21492
  const byId = new Map(stamped.map((p) => [p.id, p]));
21272
21493
  write({ projects: get().projects.map((p) => byId.get(p.id) ?? p) });
21273
21494
  const program = get().programs.find((p) => p.id === programId);
@@ -21617,6 +21838,7 @@ function tasksSlice({ set, get, write }) {
21617
21838
  ...t,
21618
21839
  id,
21619
21840
  createdAt: stamp(),
21841
+ createdById: get().currentUserId || null,
21620
21842
  completedAt: t.status === "done" ? t.completedAt ?? stamp() : null,
21621
21843
  checklist: t.checklist ?? [],
21622
21844
  ...blockersPatch(blockerIds({
@@ -24821,6 +25043,7 @@ var init_repo_sync = __esmMin((() => {
24821
25043
  //#region src/tui/try.ts
24822
25044
  init_format();
24823
25045
  init_task_keys();
25046
+ init_tide();
24824
25047
  init_view_only();
24825
25048
  init_errors();
24826
25049
  init_repo_sync();