@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/README.md +2 -0
- package/dist/app.mjs +2337 -2114
- package/lib/cli.mjs +25 -1
- package/lib/format.mjs +23 -1
- package/lib/mcp.mjs +9 -2
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -2279,2272 +2279,2395 @@ var init_premortem = __esmMin((() => {
|
|
|
2279
2279
|
}));
|
|
2280
2280
|
|
|
2281
2281
|
//#endregion
|
|
2282
|
-
//#region src/lib/
|
|
2283
|
-
/**
|
|
2284
|
-
function
|
|
2285
|
-
|
|
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
|
-
*
|
|
2289
|
-
*
|
|
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
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
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
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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
|
-
|
|
2349
|
-
|
|
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
|
-
*
|
|
2355
|
-
*
|
|
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
|
|
2358
|
-
|
|
2359
|
-
if (!
|
|
2360
|
-
|
|
2361
|
-
return
|
|
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
|
-
/**
|
|
2364
|
-
|
|
2365
|
-
|
|
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
|
-
/**
|
|
2368
|
-
function
|
|
2369
|
-
|
|
2370
|
-
|
|
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
|
-
*
|
|
2375
|
-
*
|
|
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
|
|
2380
|
-
const
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
if (
|
|
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
|
|
2399
|
+
return [...out];
|
|
2396
2400
|
}
|
|
2397
|
-
/**
|
|
2398
|
-
function
|
|
2399
|
-
if (
|
|
2400
|
-
|
|
2401
|
-
|
|
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
|
-
/**
|
|
2416
|
-
function
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
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/
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
const
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
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
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
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
|
-
|
|
2530
|
-
|
|
2440
|
+
code: "BR",
|
|
2441
|
+
name: "Brazil"
|
|
2531
2442
|
},
|
|
2532
2443
|
{
|
|
2533
|
-
|
|
2534
|
-
|
|
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
|
-
/**
|
|
2539
|
-
function
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
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
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
-
/**
|
|
2602
|
-
function
|
|
2603
|
-
const
|
|
2604
|
-
if (!
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
const
|
|
2610
|
-
const
|
|
2611
|
-
const
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
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
|
-
/**
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
}
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
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/
|
|
2695
|
-
/**
|
|
2696
|
-
function
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
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
|
|
2707
|
-
|
|
2708
|
-
|
|
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
|
-
/**
|
|
2711
|
-
function
|
|
2712
|
-
|
|
2713
|
-
return
|
|
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
|
-
|
|
2716
|
-
|
|
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
|
-
/**
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
const
|
|
2725
|
-
const
|
|
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
|
-
|
|
2728
|
-
|
|
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
|
-
/**
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
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
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
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
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
due: str(r.due).slice(0, 10)
|
|
3054
|
+
return {
|
|
3055
|
+
shares: share,
|
|
3056
|
+
crew: people
|
|
2774
3057
|
};
|
|
2775
|
-
return null;
|
|
2776
3058
|
}
|
|
2777
|
-
/**
|
|
2778
|
-
function
|
|
2779
|
-
const
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
const
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
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
|
-
/**
|
|
2791
|
-
function
|
|
2792
|
-
return
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
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
|
-
*
|
|
2803
|
-
*
|
|
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
|
|
2807
|
-
const
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
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
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
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
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
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
|
-
|
|
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/
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
"
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
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
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
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/
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
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
|
-
*
|
|
2984
|
-
*
|
|
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
|
|
2987
|
-
const
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
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
|
-
|
|
3421
|
-
|
|
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
|
|
3424
|
-
return
|
|
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
|
|
3428
|
-
*
|
|
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
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
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
|
-
|
|
3440
|
-
|
|
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
|
-
|
|
3443
|
-
|
|
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
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
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
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
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
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3447
|
+
doneWhen: clean(charter.doneWhen) || null,
|
|
3448
|
+
delivered,
|
|
3449
|
+
crept: outOfScope(charter, tasks),
|
|
3450
|
+
outItems: charterItems(charter.outScope).length
|
|
3459
3451
|
};
|
|
3460
3452
|
}
|
|
3461
|
-
|
|
3462
|
-
|
|
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
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
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
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
{
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
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
|
-
|
|
3489
|
-
|
|
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
|
-
|
|
3495
|
-
|
|
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
|
-
/**
|
|
3572
|
-
function
|
|
3573
|
-
const
|
|
3574
|
-
const
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
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/
|
|
3591
|
-
init_cascade();
|
|
3611
|
+
//#region src/lib/dead-reckoning.ts
|
|
3592
3612
|
init_date_fns();
|
|
3593
|
-
|
|
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
|
-
*
|
|
3596
|
-
*
|
|
3597
|
-
*
|
|
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
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
const
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
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
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
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
|
-
/**
|
|
3615
|
-
function
|
|
3616
|
-
|
|
3617
|
-
if (
|
|
3618
|
-
|
|
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
|
-
|
|
3621
|
-
|
|
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/
|
|
3629
|
-
/**
|
|
3630
|
-
function
|
|
3631
|
-
|
|
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
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
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
|
-
|
|
3651
|
-
|
|
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
|
-
|
|
3660
|
-
|
|
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
|
-
/**
|
|
3670
|
-
function
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
const
|
|
3680
|
-
const
|
|
3681
|
-
const
|
|
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
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
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
|
-
/**
|
|
3696
|
-
function
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
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
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
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
|
-
/**
|
|
3727
|
-
function
|
|
3728
|
-
|
|
3729
|
-
|
|
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
|
-
*
|
|
3733
|
-
*
|
|
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
|
|
3736
|
-
const
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
}
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
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
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
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
|
|
3779
|
-
var
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
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
|
-
|
|
3827
|
-
|
|
3935
|
+
issue: {
|
|
3936
|
+
description: "What's going on",
|
|
3937
|
+
resolution: "How it gets resolved",
|
|
3938
|
+
dueDate: "Needs an answer by"
|
|
3828
3939
|
},
|
|
3829
|
-
{
|
|
3830
|
-
|
|
3831
|
-
|
|
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
|
-
|
|
3835
|
-
|
|
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/
|
|
3840
|
-
init_soundings();
|
|
4006
|
+
//#region src/tui/create.ts
|
|
3841
4007
|
init_date_fns();
|
|
3842
4008
|
init_format();
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
const
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
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
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
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
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
const
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
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
|
-
/**
|
|
4194
|
-
function
|
|
4195
|
-
const
|
|
4196
|
-
const
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
const
|
|
4212
|
-
|
|
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
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
}
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
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/
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
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
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
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
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
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
|
-
|
|
4320
|
-
|
|
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
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
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
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
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
|
-
|
|
4389
|
-
|
|
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
|
-
*
|
|
4394
|
-
* (
|
|
4395
|
-
*
|
|
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
|
|
4398
|
-
const
|
|
4399
|
-
const
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
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
|
-
|
|
4423
|
-
|
|
4501
|
+
label: columnLabel(col),
|
|
4502
|
+
limit: col.wipLimit,
|
|
4503
|
+
current: currentCount,
|
|
4504
|
+
next
|
|
4424
4505
|
};
|
|
4425
4506
|
}
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
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
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
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
|
-
|
|
4456
|
-
|
|
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
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
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
|
-
*
|
|
4467
|
-
*
|
|
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
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
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
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
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
|
-
/**
|
|
4528
|
-
function
|
|
4529
|
-
|
|
4530
|
-
if (
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
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
|
-
/**
|
|
4796
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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))
|
|
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();
|