@harborppm/cli 0.58.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 +5 -2
- package/dist/app.mjs +1323 -428
- package/lib/cli.mjs +135 -36
- package/lib/format.mjs +44 -2
- package/lib/mcp.mjs +9 -2
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -2278,6 +2278,1043 @@ var init_premortem = __esmMin((() => {
|
|
|
2278
2278
|
STOP = new Set("the a an and or but of to in on for with at by from into onto was were be been being is are it its this that these those we our us they them their he she his her i my me you your had has have did do does not no so too very just than then there here what which who because about after before over under again more most some any all each other only own same can could would should will got get project failed happened went".split(" "));
|
|
2279
2279
|
}));
|
|
2280
2280
|
|
|
2281
|
+
//#endregion
|
|
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;
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
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.
|
|
2316
|
+
*/
|
|
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;
|
|
2322
|
+
}
|
|
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
|
+
};
|
|
2348
|
+
}
|
|
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);
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
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).
|
|
2357
|
+
*/
|
|
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);
|
|
2363
|
+
}
|
|
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;
|
|
2379
|
+
}
|
|
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"}`;
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
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.
|
|
2388
|
+
*/
|
|
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);
|
|
2398
|
+
}
|
|
2399
|
+
return [...out];
|
|
2400
|
+
}
|
|
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)}.`;
|
|
2406
|
+
}
|
|
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
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
var SOUNDING_MAX_DAYS;
|
|
2415
|
+
var init_soundings = __esmMin((() => {
|
|
2416
|
+
init_date_fns();
|
|
2417
|
+
SOUNDING_MAX_DAYS = 250;
|
|
2418
|
+
}));
|
|
2419
|
+
|
|
2420
|
+
//#endregion
|
|
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;
|
|
2431
|
+
}
|
|
2432
|
+
var COUNTRIES, KNOWN, countryName;
|
|
2433
|
+
var init_countries = __esmMin((() => {
|
|
2434
|
+
COUNTRIES = [
|
|
2435
|
+
{
|
|
2436
|
+
code: "AU",
|
|
2437
|
+
name: "Australia"
|
|
2438
|
+
},
|
|
2439
|
+
{
|
|
2440
|
+
code: "BR",
|
|
2441
|
+
name: "Brazil"
|
|
2442
|
+
},
|
|
2443
|
+
{
|
|
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"
|
|
2486
|
+
}
|
|
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);
|
|
2511
|
+
}
|
|
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);
|
|
2518
|
+
}
|
|
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
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
out.sort((a, b) => a.date.localeCompare(b.date));
|
|
2550
|
+
cache.set(key, out);
|
|
2551
|
+
return out;
|
|
2552
|
+
}
|
|
2553
|
+
/** Holidays in the given countries between two days, both included. */
|
|
2554
|
+
function holidaysBetween(countries, from, to) {
|
|
2555
|
+
const out = [];
|
|
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));
|
|
2560
|
+
}
|
|
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;
|
|
2587
|
+
}
|
|
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
|
+
};
|
|
2602
|
+
return {
|
|
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)
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
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
|
+
};
|
|
2633
|
+
});
|
|
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
|
+
}));
|
|
2916
|
+
|
|
2917
|
+
//#endregion
|
|
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;
|
|
2933
|
+
}
|
|
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;
|
|
2941
|
+
}
|
|
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
|
+
};
|
|
2951
|
+
}
|
|
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);
|
|
2978
|
+
}
|
|
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
|
+
}
|
|
3020
|
+
return {
|
|
3021
|
+
floors,
|
|
3022
|
+
sounded: draws.length
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
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
|
|
3036
|
+
};
|
|
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;
|
|
3053
|
+
};
|
|
3054
|
+
return {
|
|
3055
|
+
shares: share,
|
|
3056
|
+
crew: people
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
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
|
+
};
|
|
3087
|
+
}
|
|
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
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
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).
|
|
3101
|
+
*/
|
|
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
|
|
3108
|
+
};
|
|
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
|
|
3114
|
+
};
|
|
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))
|
|
3158
|
+
};
|
|
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`);
|
|
3193
|
+
}));
|
|
3194
|
+
|
|
3195
|
+
//#endregion
|
|
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"
|
|
3212
|
+
};
|
|
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
|
|
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"}`;
|
|
3316
|
+
}));
|
|
3317
|
+
|
|
2281
3318
|
//#endregion
|
|
2282
3319
|
//#region src/lib/charter-lines.ts
|
|
2283
3320
|
/** Nothing written on any of the five lines. */
|
|
@@ -2317,6 +3354,7 @@ var init_charter_lines = __esmMin((() => {
|
|
|
2317
3354
|
//#endregion
|
|
2318
3355
|
//#region src/lib/charter.ts
|
|
2319
3356
|
init_indexes();
|
|
3357
|
+
init_barnacles();
|
|
2320
3358
|
init_date_fns();
|
|
2321
3359
|
init_date_text();
|
|
2322
3360
|
init_premortem();
|
|
@@ -2493,6 +3531,7 @@ function voyageLog(data, projectId) {
|
|
|
2493
3531
|
fromBaseline: Boolean(project.baselineEnd),
|
|
2494
3532
|
tasks: tasks.length,
|
|
2495
3533
|
addedLate,
|
|
3534
|
+
barnacles: barnaclesOf(project, tasks, data.people, /* @__PURE__ */ new Date(`${finished}T12:00:00`), Math.max(0, between(plannedEnd, finished)) || null),
|
|
2496
3535
|
slips,
|
|
2497
3536
|
risks,
|
|
2498
3537
|
issues: issues.length,
|
|
@@ -2540,6 +3579,13 @@ function voyageStory(log) {
|
|
|
2540
3579
|
const out = [];
|
|
2541
3580
|
const planned = `Planned ${md$2(log.plannedStart)} to ${md$2(log.plannedEnd)}`;
|
|
2542
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
|
+
}
|
|
2543
3589
|
for (const s of log.slips) {
|
|
2544
3590
|
const late = `“${s.task.title}” finished ${plural$4(s.daysLate, "day")} late`;
|
|
2545
3591
|
out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md$2(s.waitedOn.completedAt)}.` : `${late}.`);
|
|
@@ -3549,416 +4595,80 @@ function cascadeDelete(data, kind, id) {
|
|
|
3549
4595
|
constraints,
|
|
3550
4596
|
raidItems,
|
|
3551
4597
|
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
|
-
}
|
|
3571
|
-
/** "2 projects, 14 tasks, 3 milestones and 2 risks or issues" — or "" when nothing else goes. */
|
|
3572
|
-
function cascadeSummary(counts, kind) {
|
|
3573
|
-
const bits = [];
|
|
3574
|
-
const n = (count, one, many) => count > 0 ? `${count} ${count === 1 ? one : many}` : null;
|
|
3575
|
-
if (kind === "portfolio") bits.push(n(counts.programs, "program", "programs") ?? "");
|
|
3576
|
-
if (kind !== "project") bits.push(n(counts.projects, "project", "projects") ?? "");
|
|
3577
|
-
bits.push(n(counts.tasks, "task", "tasks") ?? "");
|
|
3578
|
-
bits.push(n(counts.milestones, "milestone", "milestones") ?? "");
|
|
3579
|
-
bits.push(n(counts.raidItems, "logged risk, issue or decision", "logged risks, issues and decisions") ?? "");
|
|
3580
|
-
const list = bits.filter(Boolean);
|
|
3581
|
-
if (list.length === 0) return "";
|
|
3582
|
-
if (list.length === 1) return list[0];
|
|
3583
|
-
return `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`;
|
|
3584
|
-
}
|
|
3585
|
-
var init_cascade = __esmMin((() => {
|
|
3586
|
-
init_blockers();
|
|
3587
|
-
}));
|
|
3588
|
-
|
|
3589
|
-
//#endregion
|
|
3590
|
-
//#region src/lib/forecast.ts
|
|
3591
|
-
init_cascade();
|
|
3592
|
-
init_date_fns();
|
|
3593
|
-
const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
3594
|
-
/**
|
|
3595
|
-
* A straight-line estimate at completion from the burn so far: spend divided
|
|
3596
|
-
* by the share of the schedule elapsed. Null before anything is spent or
|
|
3597
|
-
* before the schedule starts, since there is nothing to project yet. After
|
|
3598
|
-
* the end date the estimate is the spend itself.
|
|
3599
|
-
*/
|
|
3600
|
-
function forecastAtCompletion(item, now) {
|
|
3601
|
-
if (item.spent <= 0) return null;
|
|
3602
|
-
const total = differenceInCalendarDays(day$5(item.endDate), day$5(item.startDate)) + 1;
|
|
3603
|
-
const gone = differenceInCalendarDays(now, day$5(item.startDate)) + 1;
|
|
3604
|
-
if (total <= 0 || gone <= 0) return null;
|
|
3605
|
-
const elapsed = Math.min(1, gone / total);
|
|
3606
|
-
const eac = Math.round(item.spent / elapsed);
|
|
3607
|
-
return {
|
|
3608
|
-
eac,
|
|
3609
|
-
variance: item.budget - eac,
|
|
3610
|
-
elapsed,
|
|
3611
|
-
tone: item.budget <= 0 ? "over" : eac > item.budget * 1.05 ? "over" : eac > item.budget * .95 ? "tight" : "under"
|
|
3612
|
-
};
|
|
3613
|
-
}
|
|
3614
|
-
/** Sum of the children's estimates, using spend where a child has no forecast yet. */
|
|
3615
|
-
function forecastRollup(items, budget, now) {
|
|
3616
|
-
const eac = items.map((i) => forecastAtCompletion(i, now)?.eac ?? i.spent).reduce((a, b) => a + b, 0);
|
|
3617
|
-
if (eac <= 0) return null;
|
|
3618
|
-
const elapsed = Math.max(0, ...items.map((i) => forecastAtCompletion(i, now)?.elapsed ?? 0));
|
|
3619
|
-
return {
|
|
3620
|
-
eac,
|
|
3621
|
-
variance: budget - eac,
|
|
3622
|
-
elapsed,
|
|
3623
|
-
tone: budget <= 0 ? "over" : eac > budget * 1.05 ? "over" : eac > budget * .95 ? "tight" : "under"
|
|
3624
|
-
};
|
|
3625
|
-
}
|
|
3626
|
-
|
|
3627
|
-
//#endregion
|
|
3628
|
-
//#region src/lib/soundings.ts
|
|
3629
|
-
/** Why a number can't be an answer, or null when it can: half days up to SOUNDING_MAX_DAYS. */
|
|
3630
|
-
function daysProblem(days) {
|
|
3631
|
-
if (!Number.isFinite(days) || days <= 0) return "Give a number of days above 0, like 3 or 0.5.";
|
|
3632
|
-
if (days > 250) return `Keep it to ${250} working days or fewer.`;
|
|
3633
|
-
if (Math.round(days * 2) !== days * 2) return "Use whole or half days, like 3 or 2.5.";
|
|
3634
|
-
return null;
|
|
3635
|
-
}
|
|
3636
|
-
/** Each asked person's latest answer to the task's current sounding, oldest first. */
|
|
3637
|
-
function answersFor(reflections, task) {
|
|
3638
|
-
const s = task.sounding;
|
|
3639
|
-
if (!s) return [];
|
|
3640
|
-
const crew = new Set(s.crewIds);
|
|
3641
|
-
const latest = /* @__PURE__ */ new Map();
|
|
3642
|
-
for (const r of reflections ?? []) {
|
|
3643
|
-
if (r.kind !== "sounding" || r.taskId !== task.id || typeof r.days !== "number") continue;
|
|
3644
|
-
if (r.createdAt < s.askedAt || !crew.has(r.authorId)) continue;
|
|
3645
|
-
const had = latest.get(r.authorId);
|
|
3646
|
-
if (!had || r.createdAt >= had.createdAt) latest.set(r.authorId, r);
|
|
3647
|
-
}
|
|
3648
|
-
return [...latest.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
3649
|
-
}
|
|
3650
|
-
/** Middle of the guesses, to the half day. */
|
|
3651
|
-
function middleOf(days) {
|
|
3652
|
-
if (days.length === 0) return null;
|
|
3653
|
-
const sorted = [...days].sort((a, b) => a - b);
|
|
3654
|
-
const mid = Math.floor(sorted.length / 2);
|
|
3655
|
-
const m = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
3656
|
-
return Math.round(m * 2) / 2;
|
|
3657
|
-
}
|
|
3658
|
-
/**
|
|
3659
|
-
* Guesses far enough apart to talk about first: the highest at least twice
|
|
3660
|
-
* the lowest and three or more days from it. 3 against 5 is agreement; 2
|
|
3661
|
-
* against 15, or 5 against 10, is two people seeing different work.
|
|
3662
|
-
*/
|
|
3663
|
-
function isWide(days) {
|
|
3664
|
-
if (days.length < 2) return false;
|
|
3665
|
-
const low = Math.min(...days);
|
|
3666
|
-
const high = Math.max(...days);
|
|
3667
|
-
return high - low >= 3 && high >= low * 2;
|
|
3668
|
-
}
|
|
3669
|
-
/** Where a task's sounding stands, or null when nobody asked. */
|
|
3670
|
-
function readSounding(task, reflections, now) {
|
|
3671
|
-
const s = task.sounding;
|
|
3672
|
-
if (!s) return null;
|
|
3673
|
-
const answers = answersFor(reflections, task);
|
|
3674
|
-
const by = new Set(answers.map((a) => a.authorId));
|
|
3675
|
-
const waitingOn = s.crewIds.filter((id) => !by.has(id));
|
|
3676
|
-
const early = Boolean(s.shownAt && Date.parse(s.shownAt) <= now.getTime());
|
|
3677
|
-
const shown = answers.length > 0 && (waitingOn.length === 0 || early);
|
|
3678
|
-
const days = answers.map((a) => a.days);
|
|
3679
|
-
const wide = shown && isWide(days);
|
|
3680
|
-
const middle = middleOf(days);
|
|
3681
|
-
const settled = s.settledDays ?? null;
|
|
3682
|
-
return {
|
|
3683
|
-
sounding: s,
|
|
3684
|
-
crewIds: s.crewIds,
|
|
3685
|
-
answers,
|
|
3686
|
-
waitingOn,
|
|
3687
|
-
shown,
|
|
3688
|
-
low: days.length ? Math.min(...days) : null,
|
|
3689
|
-
high: days.length ? Math.max(...days) : null,
|
|
3690
|
-
middle,
|
|
3691
|
-
wide,
|
|
3692
|
-
estimate: settled ?? (shown && !wide ? middle : null)
|
|
3693
|
-
};
|
|
3694
|
-
}
|
|
3695
|
-
/** What one person may see: their own answer always; everyone's once shown. */
|
|
3696
|
-
function visibleAnswers(read, personId) {
|
|
3697
|
-
return read.shown ? read.answers : read.answers.filter((a) => a.authorId === personId);
|
|
3698
|
-
}
|
|
3699
|
-
/**
|
|
3700
|
-
* The answers the forecast draws from for an open task: the settled number
|
|
3701
|
-
* alone once there is one, otherwise every answer in so far (the spread is
|
|
3702
|
-
* the point, and the forecast names nobody's guess).
|
|
3703
|
-
*/
|
|
3704
|
-
function soundingDraws(task, reflections) {
|
|
3705
|
-
const s = task.sounding;
|
|
3706
|
-
if (!s) return [];
|
|
3707
|
-
if (s.settledDays != null) return [s.settledDays];
|
|
3708
|
-
return answersFor(reflections, task).map((a) => a.days);
|
|
3709
|
-
}
|
|
3710
|
-
/** Calendar days from `from` until `days` working days (Monday to Friday) have passed. */
|
|
3711
|
-
function workingDaysAhead(days, from) {
|
|
3712
|
-
let left = Math.ceil(days);
|
|
3713
|
-
let d = 0;
|
|
3714
|
-
while (left > 0) {
|
|
3715
|
-
d += 1;
|
|
3716
|
-
const wd = addDays(from, d).getDay();
|
|
3717
|
-
if (wd !== 0 && wd !== 6) left -= 1;
|
|
3718
|
-
}
|
|
3719
|
-
return d;
|
|
3720
|
-
}
|
|
3721
|
-
/** "half a day", "1 day", "2.5 days". */
|
|
3722
|
-
function daysText(days) {
|
|
3723
|
-
if (days === .5) return "half a day";
|
|
3724
|
-
return `${days} day${days === 1 ? "" : "s"}`;
|
|
3725
|
-
}
|
|
3726
|
-
/**
|
|
3727
|
-
* Who a new sounding asks unless you pick: you, the task's owner, and the
|
|
3728
|
-
* people with open work on its project, agents left out.
|
|
3729
|
-
*/
|
|
3730
|
-
function suggestedCrew(data, task, meId) {
|
|
3731
|
-
const people = new Map(data.people.filter((p) => p.kind !== "agent").map((p) => [p.id, p]));
|
|
3732
|
-
const out = /* @__PURE__ */ new Set();
|
|
3733
|
-
if (people.has(meId)) out.add(meId);
|
|
3734
|
-
if (task.assigneeId && people.has(task.assigneeId)) out.add(task.assigneeId);
|
|
3735
|
-
for (const t of data.tasks) {
|
|
3736
|
-
if (out.size >= 6) break;
|
|
3737
|
-
if (t.projectId !== task.projectId || t.status === "done" || !t.assigneeId) continue;
|
|
3738
|
-
if (people.has(t.assigneeId)) out.add(t.assigneeId);
|
|
3739
|
-
}
|
|
3740
|
-
return [...out];
|
|
3741
|
-
}
|
|
3742
|
-
/** "Close enough: about 4 days", or why it needs talking through. */
|
|
3743
|
-
function soundingVerdict(read) {
|
|
3744
|
-
if (read.sounding.settledDays != null) return `Settled on ${daysText(read.sounding.settledDays)}.`;
|
|
3745
|
-
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.`;
|
|
3746
|
-
return `Close enough: about ${daysText(read.estimate)}.`;
|
|
3747
|
-
}
|
|
3748
|
-
/** Open tasks whose sounding waits on this person's answer. */
|
|
3749
|
-
function soundingsWaitingOn(data, personId, now) {
|
|
3750
|
-
return data.tasks.filter((t) => {
|
|
3751
|
-
if (t.status === "done" || !t.sounding?.crewIds.includes(personId)) return false;
|
|
3752
|
-
return readSounding(t, data.reflections, now).waitingOn.includes(personId);
|
|
3753
|
-
});
|
|
3754
|
-
}
|
|
3755
|
-
var SOUNDING_MAX_DAYS;
|
|
3756
|
-
var init_soundings = __esmMin((() => {
|
|
3757
|
-
init_date_fns();
|
|
3758
|
-
SOUNDING_MAX_DAYS = 250;
|
|
3759
|
-
}));
|
|
3760
|
-
|
|
3761
|
-
//#endregion
|
|
3762
|
-
//#region src/lib/tide.ts
|
|
3763
|
-
init_date_fns();
|
|
3764
|
-
init_format();
|
|
3765
|
-
init_soundings();
|
|
3766
|
-
/**
|
|
3767
|
-
* The tide forecast: when a project will likely finish, from how fast its
|
|
3768
|
-
* crew has actually closed tasks. Each of a thousand runs replays the coming
|
|
3769
|
-
* weeks by drawing at random from the project's recent weekly counts, until
|
|
3770
|
-
* the open tasks run out; the spread of finish days is the forecast. No
|
|
3771
|
-
* estimates or story points: only tasks and when they were finished.
|
|
3772
|
-
*
|
|
3773
|
-
* Seeded from the project and the day, so the same project reads the same
|
|
3774
|
-
* on the server and the client and doesn't jitter between renders.
|
|
3775
|
-
*/
|
|
3776
|
-
/** How many weeks back the crew's pace is read from. */
|
|
3777
|
-
const TIDE_WEEKS = 6;
|
|
3778
|
-
const TRIALS = 1e3;
|
|
3779
|
-
/** A run that hasn't finished in two years is counted as not finishing. */
|
|
3780
|
-
const MAX_WEEKS = 104;
|
|
3781
|
-
/** Fewer finished tasks than this and the forecast says it's rough. */
|
|
3782
|
-
const ROUGH_BELOW = 5;
|
|
3783
|
-
const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
3784
|
-
/** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
|
|
3785
|
-
function weeklyFinished(tasks, startDate, now, weeks = 6) {
|
|
3786
|
-
const today = noon$2(todayISO(now));
|
|
3787
|
-
const age = Math.floor(differenceInCalendarDays(today, noon$2(startDate)) / 7) + 1;
|
|
3788
|
-
const span = Math.max(1, Math.min(weeks, age));
|
|
3789
|
-
const counts = new Array(span).fill(0);
|
|
3790
|
-
for (const t of tasks) {
|
|
3791
|
-
if (t.status !== "done") continue;
|
|
3792
|
-
const at = t.completedAt ? noon$2(t.completedAt) : today;
|
|
3793
|
-
const back = Math.max(0, differenceInCalendarDays(today, at));
|
|
3794
|
-
const week = Math.floor(back / 7);
|
|
3795
|
-
if (week < span) counts[span - 1 - week] += 1;
|
|
3796
|
-
}
|
|
3797
|
-
return counts;
|
|
3798
|
-
}
|
|
3799
|
-
function hash(text) {
|
|
3800
|
-
let h = 2166136261;
|
|
3801
|
-
for (let i = 0; i < text.length; i++) {
|
|
3802
|
-
h ^= text.charCodeAt(i);
|
|
3803
|
-
h = Math.imul(h, 16777619);
|
|
3804
|
-
}
|
|
3805
|
-
return h >>> 0;
|
|
3806
|
-
}
|
|
3807
|
-
/** mulberry32: small, fast, and the same everywhere for the same seed. */
|
|
3808
|
-
function random(seed) {
|
|
3809
|
-
let a = seed;
|
|
3810
|
-
return () => {
|
|
3811
|
-
a = a + 1831565813 | 0;
|
|
3812
|
-
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
3813
|
-
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
3814
|
-
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
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
|
+
}
|
|
3815
4615
|
};
|
|
3816
4616
|
}
|
|
3817
|
-
/**
|
|
3818
|
-
function
|
|
3819
|
-
const
|
|
3820
|
-
const
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
days = week * 7 + landed[left - 1];
|
|
3831
|
-
break;
|
|
3832
|
-
}
|
|
3833
|
-
left -= closed;
|
|
3834
|
-
}
|
|
3835
|
-
out.push(days);
|
|
3836
|
-
}
|
|
3837
|
-
return out.sort((a, b) => a - b);
|
|
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)}`;
|
|
3838
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`);
|
|
3839
4640
|
/**
|
|
3840
|
-
*
|
|
3841
|
-
*
|
|
3842
|
-
*
|
|
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.
|
|
3843
4645
|
*/
|
|
3844
|
-
function
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
days,
|
|
3852
|
-
share: t.status === "doing" || t.status === "review" ? .5 : 1
|
|
3853
|
-
});
|
|
3854
|
-
}
|
|
3855
|
-
if (draws.length === 0) return {
|
|
3856
|
-
floors: null,
|
|
3857
|
-
sounded: 0
|
|
3858
|
-
};
|
|
3859
|
-
const next = random(seed);
|
|
3860
|
-
const memo = /* @__PURE__ */ new Map();
|
|
3861
|
-
const ahead = (d) => {
|
|
3862
|
-
let v = memo.get(d);
|
|
3863
|
-
if (v === void 0) memo.set(d, v = workingDaysAhead(d, base));
|
|
3864
|
-
return v;
|
|
3865
|
-
};
|
|
3866
|
-
const floors = [];
|
|
3867
|
-
for (let run = 0; run < TRIALS; run++) {
|
|
3868
|
-
let most = 0;
|
|
3869
|
-
for (const x of draws) {
|
|
3870
|
-
const d = x.days[Math.floor(next() * x.days.length)] * x.share;
|
|
3871
|
-
most = Math.max(most, ahead(d));
|
|
3872
|
-
}
|
|
3873
|
-
floors.push(most);
|
|
3874
|
-
}
|
|
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);
|
|
3875
4653
|
return {
|
|
3876
|
-
|
|
3877
|
-
|
|
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"
|
|
3878
4658
|
};
|
|
3879
4659
|
}
|
|
3880
|
-
/**
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
const remaining = tasks.filter((t) => t.status !== "done").length;
|
|
3886
|
-
if (remaining === 0) return { kind: "done" };
|
|
3887
|
-
if (project.health === "paused") return {
|
|
3888
|
-
kind: "paused",
|
|
3889
|
-
remaining
|
|
3890
|
-
};
|
|
3891
|
-
const weekly = weeklyFinished(tasks, project.startDate, now);
|
|
3892
|
-
const finished = weekly.reduce((a, b) => a + b, 0);
|
|
3893
|
-
if (finished === 0) return {
|
|
3894
|
-
kind: "no-pace",
|
|
3895
|
-
remaining
|
|
3896
|
-
};
|
|
3897
|
-
const today = todayISO(now);
|
|
3898
|
-
const base = noon$2(today);
|
|
3899
|
-
const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1));
|
|
3900
|
-
const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
|
|
3901
|
-
let lastDue = null;
|
|
3902
|
-
for (const t of tasks) {
|
|
3903
|
-
if (t.status === "done" || !t.dueDate) continue;
|
|
3904
|
-
if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
|
|
3905
|
-
}
|
|
3906
|
-
const floor = lastDue ? differenceInCalendarDays(noon$2(lastDue.dueDate), base) : 0;
|
|
3907
|
-
const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`));
|
|
3908
|
-
const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
|
|
3909
|
-
const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
|
|
3910
|
-
const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
|
|
3911
|
-
const toEnd = differenceInCalendarDays(noon$2(project.endDate), base);
|
|
3912
|
-
const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
|
|
3913
|
-
const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
|
|
3914
|
-
const curve = [];
|
|
3915
|
-
const step = Math.max(1, Math.ceil(last / 60));
|
|
3916
|
-
let i = 0;
|
|
3917
|
-
for (let d = 0; d <= last; d += step) {
|
|
3918
|
-
while (i < runs.length && runs[i] <= d) i++;
|
|
3919
|
-
curve.push({
|
|
3920
|
-
date: dayOf(d),
|
|
3921
|
-
chance: i / runs.length
|
|
3922
|
-
});
|
|
3923
|
-
}
|
|
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));
|
|
3924
4665
|
return {
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
perWeek: Math.round(finished / weekly.length * Math.max(0, opts.pace ?? 1) * 10) / 10,
|
|
3930
|
-
likely: dayOf(at(.5)),
|
|
3931
|
-
veryLikely: dayOf(at(.85)),
|
|
3932
|
-
almostCertain: dayOf(at(.95)),
|
|
3933
|
-
chanceByEnd: Math.round(byEnd * 100) / 100,
|
|
3934
|
-
curve,
|
|
3935
|
-
rough: finished < ROUGH_BELOW,
|
|
3936
|
-
heldBy,
|
|
3937
|
-
sounded: sounded.sounded
|
|
4666
|
+
eac,
|
|
4667
|
+
variance: budget - eac,
|
|
4668
|
+
elapsed,
|
|
4669
|
+
tone: budget <= 0 ? "over" : eac > budget * 1.05 ? "over" : eac > budget * .95 ? "tight" : "under"
|
|
3938
4670
|
};
|
|
3939
4671
|
}
|
|
3940
|
-
/** "15%", with "under 5%" and "over 95%" at the ends, where a thousand runs can't be that sure. */
|
|
3941
|
-
function chanceText(chance) {
|
|
3942
|
-
if (chance < .05) return "under 5%";
|
|
3943
|
-
if (chance > .95) return "over 95%";
|
|
3944
|
-
return `${Math.round(chance * 20) * 5}%`;
|
|
3945
|
-
}
|
|
3946
|
-
/**
|
|
3947
|
-
* The open task whose due date sets the finish, when the crew's pace alone
|
|
3948
|
-
* would be done sooner; null otherwise.
|
|
3949
|
-
*/
|
|
3950
|
-
function dueAfterFinish(tide) {
|
|
3951
|
-
return tide.kind === "forecast" ? tide.heldBy : null;
|
|
3952
|
-
}
|
|
3953
|
-
/** Why there's no forecast, for the states without one. */
|
|
3954
|
-
function tideQuiet(tide) {
|
|
3955
|
-
switch (tide.kind) {
|
|
3956
|
-
case "done": return "Every task is done.";
|
|
3957
|
-
case "paused": return "Paused, so its pace says nothing about when it ends.";
|
|
3958
|
-
case "no-pace": return `Nothing finished in the last ${6} weeks, so there's no pace to forecast from yet.`;
|
|
3959
|
-
default: return null;
|
|
3960
|
-
}
|
|
3961
|
-
}
|
|
3962
4672
|
|
|
3963
4673
|
//#endregion
|
|
3964
4674
|
//#region src/lib/flow.ts
|
|
@@ -4205,12 +4915,17 @@ function slipDays(p) {
|
|
|
4205
4915
|
if (!p.baselineEnd) return null;
|
|
4206
4916
|
return differenceInCalendarDays(day$3(p.endDate), day$3(p.baselineEnd));
|
|
4207
4917
|
}
|
|
4208
|
-
/**
|
|
4209
|
-
|
|
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) {
|
|
4210
4924
|
return projects.map((p) => hasBaseline(p) && !reset ? p : {
|
|
4211
4925
|
...p,
|
|
4212
4926
|
baselineStart: p.startDate,
|
|
4213
|
-
baselineEnd: p.endDate
|
|
4927
|
+
baselineEnd: p.endDate,
|
|
4928
|
+
...at ? { baselineAt: at } : {}
|
|
4214
4929
|
});
|
|
4215
4930
|
}
|
|
4216
4931
|
/** A caption for the timeline: "slipped 12 days", "12 days early", or null when on the baseline. */
|
|
@@ -4620,6 +5335,7 @@ init_date_fns();
|
|
|
4620
5335
|
init_format();
|
|
4621
5336
|
init_indexes();
|
|
4622
5337
|
init_spend();
|
|
5338
|
+
init_tide();
|
|
4623
5339
|
/**
|
|
4624
5340
|
* The cost of a slip: what each week past a project's end date costs, from
|
|
4625
5341
|
* how fast its spend ledger has been burning, set against the tide forecast's
|
|
@@ -4689,9 +5405,10 @@ function slipHeadline(cost) {
|
|
|
4689
5405
|
*/
|
|
4690
5406
|
function programSlipCost(data, programId, now) {
|
|
4691
5407
|
let sum = 0;
|
|
5408
|
+
const opts = tideOptions(data);
|
|
4692
5409
|
for (const p of data.projects) {
|
|
4693
5410
|
if (p.programId !== programId || p.budget <= 0 || p.health === "done") continue;
|
|
4694
|
-
const tide = tideForecast(p, tasksOf(data.tasks, p.id), now);
|
|
5411
|
+
const tide = tideForecast(p, tasksOf(data.tasks, p.id), now, opts);
|
|
4695
5412
|
sum += slipCost(p, tide, spendEntriesOf(data, p.id), now)?.likely?.cost ?? 0;
|
|
4696
5413
|
}
|
|
4697
5414
|
return sum;
|
|
@@ -4744,6 +5461,7 @@ var init_work = __esmMin((() => {
|
|
|
4744
5461
|
init_date_fns();
|
|
4745
5462
|
init_date_text();
|
|
4746
5463
|
init_indexes();
|
|
5464
|
+
init_tide();
|
|
4747
5465
|
init_format();
|
|
4748
5466
|
init_raid();
|
|
4749
5467
|
init_spend();
|
|
@@ -4966,7 +5684,7 @@ function filterTasks(tasks, f) {
|
|
|
4966
5684
|
*/
|
|
4967
5685
|
function tideLine(data, project, today) {
|
|
4968
5686
|
const tasks = tasksOf(data.tasks, project.id);
|
|
4969
|
-
const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`),
|
|
5687
|
+
const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`), tideOptions(data));
|
|
4970
5688
|
if (tide.kind === "no-pace") return {
|
|
4971
5689
|
text: "No forecast yet: nothing finished in the last few weeks",
|
|
4972
5690
|
late: false
|
|
@@ -5373,7 +6091,7 @@ function keepText(keep, now) {
|
|
|
5373
6091
|
*/
|
|
5374
6092
|
function slipLine(data, project, today) {
|
|
5375
6093
|
const now = parseIso(`${today}T12:00:00`);
|
|
5376
|
-
const tide = tideForecast(project, tasksOf(data.tasks, project.id), now);
|
|
6094
|
+
const tide = tideForecast(project, tasksOf(data.tasks, project.id), now, tideOptions(data));
|
|
5377
6095
|
const cost = slipCost(project, tide, spendEntriesOf(data, project.id), now);
|
|
5378
6096
|
if (!cost?.likely || cost.likely.daysLate <= 0) return null;
|
|
5379
6097
|
return slipHeadline(cost);
|
|
@@ -6318,6 +7036,16 @@ var init_decisions = __esmMin((() => {
|
|
|
6318
7036
|
|
|
6319
7037
|
//#endregion
|
|
6320
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
|
+
}
|
|
6321
7049
|
/** Late tasks worst first, each with what it holds up, so the letter says what to chase. */
|
|
6322
7050
|
function lateLinesOf(data, tasks, now) {
|
|
6323
7051
|
const waiters = waitersByBlocker(tasks);
|
|
@@ -6425,6 +7153,11 @@ function composeBriefBody(opts) {
|
|
|
6425
7153
|
for (const d of opts.revisit) lines.push(`• ${d.title}${d.where ? ` (${d.where})` : ""}: ${d.reason}`);
|
|
6426
7154
|
lines.push("");
|
|
6427
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
|
+
}
|
|
6428
7161
|
const done = () => {
|
|
6429
7162
|
if (!opts.shipped?.length) return;
|
|
6430
7163
|
lines.push("Done this week");
|
|
@@ -6674,6 +7407,7 @@ function briefOf(data, opts = {}) {
|
|
|
6674
7407
|
projectIds,
|
|
6675
7408
|
portfolioId
|
|
6676
7409
|
}), todayISO(now)),
|
|
7410
|
+
barnacles: barnacleLinesOf(data, projects, now),
|
|
6677
7411
|
talk,
|
|
6678
7412
|
milestonesSoon,
|
|
6679
7413
|
milestonesLate,
|
|
@@ -6730,6 +7464,10 @@ function composeBriefBodyFrom(brief, data, workspace, all = false) {
|
|
|
6730
7464
|
title: d.item.title,
|
|
6731
7465
|
reason: d.reason,
|
|
6732
7466
|
where: d.where
|
|
7467
|
+
})),
|
|
7468
|
+
barnacles: brief.barnacles.map((b) => ({
|
|
7469
|
+
project: b.project.name,
|
|
7470
|
+
line: barnaclesShort(b.barnacles)
|
|
6733
7471
|
}))
|
|
6734
7472
|
});
|
|
6735
7473
|
}
|
|
@@ -6741,7 +7479,7 @@ function briefHidden(brief) {
|
|
|
6741
7479
|
const over = (n) => Math.max(0, n - 8);
|
|
6742
7480
|
return over(brief.late.length) + over(brief.finished.length);
|
|
6743
7481
|
}
|
|
6744
|
-
var BRIEF_QUIET, BRIEF_LATE_SHOWN;
|
|
7482
|
+
var BRIEF_BARNACLES_SHOWN, BRIEF_QUIET, BRIEF_LATE_SHOWN;
|
|
6745
7483
|
var init_brief = __esmMin((() => {
|
|
6746
7484
|
init_date_fns();
|
|
6747
7485
|
init_date_text();
|
|
@@ -6755,6 +7493,9 @@ var init_brief = __esmMin((() => {
|
|
|
6755
7493
|
init_clock();
|
|
6756
7494
|
init_bottle();
|
|
6757
7495
|
init_decisions();
|
|
7496
|
+
init_barnacles();
|
|
7497
|
+
init_baseline();
|
|
7498
|
+
BRIEF_BARNACLES_SHOWN = 5;
|
|
6758
7499
|
BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
|
|
6759
7500
|
BRIEF_LATE_SHOWN = 8;
|
|
6760
7501
|
}));
|
|
@@ -8843,14 +9584,28 @@ function InboxView({ active, width, height, go }) {
|
|
|
8843
9584
|
//#endregion
|
|
8844
9585
|
//#region src/lib/load.ts
|
|
8845
9586
|
/** Open tasks for one person, bucketed by the week they are due, plus what is already late or has no date. */
|
|
8846
|
-
function weeklyLoad(tasks, personId, now, weeks = 6) {
|
|
9587
|
+
function weeklyLoad(tasks, personId, now, weeks = 6, calendar) {
|
|
8847
9588
|
const mine = tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
8848
9589
|
const today = iso$2(now);
|
|
8849
9590
|
const origin = startOfWeek(now, { weekStartsOn: 1 });
|
|
8850
|
-
const buckets = Array.from({ length: weeks }, (_, i) =>
|
|
8851
|
-
start
|
|
8852
|
-
|
|
8853
|
-
|
|
9591
|
+
const buckets = Array.from({ length: weeks }, (_, i) => {
|
|
9592
|
+
const start = iso$2(addDays(origin, i * 7));
|
|
9593
|
+
const off = [];
|
|
9594
|
+
if (calendar?.active) for (let d = 0; d < 5; d++) {
|
|
9595
|
+
const date = iso$2(addDays(origin, i * 7 + d));
|
|
9596
|
+
const why = calendar.offOn(personId, date);
|
|
9597
|
+
if (why) off.push({
|
|
9598
|
+
date,
|
|
9599
|
+
why
|
|
9600
|
+
});
|
|
9601
|
+
}
|
|
9602
|
+
return {
|
|
9603
|
+
start,
|
|
9604
|
+
due: 0,
|
|
9605
|
+
workDays: 5 - off.length,
|
|
9606
|
+
off
|
|
9607
|
+
};
|
|
9608
|
+
});
|
|
8854
9609
|
let overdue = 0;
|
|
8855
9610
|
let undated = 0;
|
|
8856
9611
|
for (const t of mine) {
|
|
@@ -11989,6 +12744,7 @@ init_format();
|
|
|
11989
12744
|
init_indexes();
|
|
11990
12745
|
init_load();
|
|
11991
12746
|
init_task_keys();
|
|
12747
|
+
init_tide();
|
|
11992
12748
|
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
11993
12749
|
const MAX_ADD = 200;
|
|
11994
12750
|
/**
|
|
@@ -17647,7 +18403,8 @@ function chainOf(line, walk, target, today) {
|
|
|
17647
18403
|
links,
|
|
17648
18404
|
finish,
|
|
17649
18405
|
target,
|
|
17650
|
-
over: target ? days$1(target, finish) : null
|
|
18406
|
+
over: target ? days$1(target, finish) : null,
|
|
18407
|
+
undated: 0
|
|
17651
18408
|
};
|
|
17652
18409
|
}
|
|
17653
18410
|
/** What sets a project's date: the line behind its open task that finishes last. */
|
|
@@ -17672,7 +18429,11 @@ function projectPole(project, all, now) {
|
|
|
17672
18429
|
kind: "none",
|
|
17673
18430
|
reason: "None of its open tasks has a due date, so there's no date to trace."
|
|
17674
18431
|
};
|
|
17675
|
-
|
|
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
|
+
};
|
|
17676
18437
|
}
|
|
17677
18438
|
/** What holds one task up: the line of open tasks it waits on, ending at it. */
|
|
17678
18439
|
function taskPole(task, all, now) {
|
|
@@ -17738,7 +18499,8 @@ function milestonePole(milestone, data, now) {
|
|
|
17738
18499
|
project: first.project,
|
|
17739
18500
|
finish: first.finish
|
|
17740
18501
|
},
|
|
17741
|
-
others
|
|
18502
|
+
others,
|
|
18503
|
+
undated: first.pole.kind === "chain" ? first.pole.undated : 0
|
|
17742
18504
|
};
|
|
17743
18505
|
}
|
|
17744
18506
|
const plural$2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
@@ -17763,6 +18525,12 @@ function poleHeadline(pole, name, held) {
|
|
|
17763
18525
|
if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
|
|
17764
18526
|
return `${lead}, ${plural$2(-pole.over, "day")} before ${held}.${tail}`;
|
|
17765
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
|
+
}
|
|
17766
18534
|
|
|
17767
18535
|
//#endregion
|
|
17768
18536
|
//#region src/lib/csv.ts
|
|
@@ -18156,6 +18924,8 @@ var init_passage = __esmMin((() => {
|
|
|
18156
18924
|
init_task_keys();
|
|
18157
18925
|
init_brief();
|
|
18158
18926
|
init_indexes();
|
|
18927
|
+
init_tide();
|
|
18928
|
+
init_barnacles();
|
|
18159
18929
|
init_spend();
|
|
18160
18930
|
init_hill();
|
|
18161
18931
|
init_raid();
|
|
@@ -18173,6 +18943,7 @@ init_work();
|
|
|
18173
18943
|
init_fog();
|
|
18174
18944
|
init_passage();
|
|
18175
18945
|
init_board();
|
|
18946
|
+
init_holidays();
|
|
18176
18947
|
init_soundings();
|
|
18177
18948
|
/**
|
|
18178
18949
|
* Where the website opens one task or project, switching to its harbor
|
|
@@ -18195,6 +18966,30 @@ function publicRoadmap(ctx, projectId) {
|
|
|
18195
18966
|
...ctx.origin ? { url: `${ctx.origin}/roadmap/${token}` } : {}
|
|
18196
18967
|
};
|
|
18197
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
|
+
}
|
|
18198
18993
|
/** A question the tool can't answer as asked; the agent sees the message. */
|
|
18199
18994
|
var ToolError = class extends Error {};
|
|
18200
18995
|
/**
|
|
@@ -18225,8 +19020,8 @@ const STATUSES = [
|
|
|
18225
19020
|
* for an agent: dates for half, 85% and 95% of the runs, or why there's none.
|
|
18226
19021
|
* Given the spend ledger, also what each week late costs (src/lib/slip-cost.ts).
|
|
18227
19022
|
*/
|
|
18228
|
-
function finishForecast(project, tasks, now, spend,
|
|
18229
|
-
const tide = tideForecast(project, tasks, now,
|
|
19023
|
+
function finishForecast(project, tasks, now, spend, data) {
|
|
19024
|
+
const tide = tideForecast(project, tasks, now, data ? tideOptions(data) : {});
|
|
18230
19025
|
if (tide.kind !== "forecast") return { none: tideQuiet(tide) };
|
|
18231
19026
|
const lastDue = dueAfterFinish(tide);
|
|
18232
19027
|
const dueLater = lastDue ? { lastDue: {
|
|
@@ -18253,6 +19048,14 @@ function finishForecast(project, tasks, now, spend, reflections) {
|
|
|
18253
19048
|
basedOn: `${tide.finished} task${tide.finished === 1 ? "" : "s"} finished in the last ${tide.weeks} week${tide.weeks === 1 ? "" : "s"}`,
|
|
18254
19049
|
...tide.rough ? { rough: true } : {},
|
|
18255
19050
|
...tide.sounded ? { soundedTasks: tide.sounded } : {},
|
|
19051
|
+
...tide.ashore.holidays.length || tide.ashore.awayDays ? { timeAshore: {
|
|
19052
|
+
holidays: tide.ashore.holidays.map((h) => ({
|
|
19053
|
+
date: h.date,
|
|
19054
|
+
name: h.name,
|
|
19055
|
+
country: h.country
|
|
19056
|
+
})),
|
|
19057
|
+
awayDays: tide.ashore.awayDays
|
|
19058
|
+
} } : {},
|
|
18256
19059
|
...tide.weeks < 2 ? { shortHistory: true } : {},
|
|
18257
19060
|
...dueLater,
|
|
18258
19061
|
...slip
|
|
@@ -18684,7 +19487,7 @@ const HARBOR_TOOLS = [
|
|
|
18684
19487
|
done: c.done,
|
|
18685
19488
|
late: c.late
|
|
18686
19489
|
},
|
|
18687
|
-
finish: finishForecast(p, tasksOf(data.tasks, p.id), ctx.now, spendBy.get(p.id) ?? [], data
|
|
19490
|
+
finish: finishForecast(p, tasksOf(data.tasks, p.id), ctx.now, spendBy.get(p.id) ?? [], data),
|
|
18688
19491
|
...limitsOf(data, p.id),
|
|
18689
19492
|
...hillOf(p, today),
|
|
18690
19493
|
...publicRoadmap(ctx, p.id)
|
|
@@ -18755,7 +19558,7 @@ const HARBOR_TOOLS = [
|
|
|
18755
19558
|
waitsOn,
|
|
18756
19559
|
milestones,
|
|
18757
19560
|
openRaid: raid,
|
|
18758
|
-
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data
|
|
19561
|
+
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
|
|
18759
19562
|
...charterOf(p, tasks),
|
|
18760
19563
|
...notesOf(ctx, p),
|
|
18761
19564
|
...hillOf(p, today),
|
|
@@ -18943,7 +19746,7 @@ const HARBOR_TOOLS = [
|
|
|
18943
19746
|
tool({
|
|
18944
19747
|
name: "weekly_brief",
|
|
18945
19748
|
title: "Weekly brief",
|
|
18946
|
-
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,
|
|
18947
19750
|
input: object({
|
|
18948
19751
|
harbor: harborArg,
|
|
18949
19752
|
portfolio: string().max(200).optional().describe("Portfolio id or name; all when left out."),
|
|
@@ -19217,6 +20020,7 @@ const HARBOR_TOOLS = [
|
|
|
19217
20020
|
...d.rationale?.trim() ? { why: d.rationale.trim() } : {},
|
|
19218
20021
|
...d.status === "superseded" ? { changedLater: true } : {}
|
|
19219
20022
|
})),
|
|
20023
|
+
barnacles: barnaclesData(log.barnacles),
|
|
19220
20024
|
closedBy: log.carriers.map((c) => ({
|
|
19221
20025
|
who: c.person.name,
|
|
19222
20026
|
tasks: c.closed
|
|
@@ -19258,10 +20062,44 @@ const HARBOR_TOOLS = [
|
|
|
19258
20062
|
};
|
|
19259
20063
|
}
|
|
19260
20064
|
}),
|
|
20065
|
+
tool({
|
|
20066
|
+
name: "time_ashore",
|
|
20067
|
+
title: "Time ashore",
|
|
20068
|
+
description: "Whose public holidays the harbor takes (country codes), anyone who takes another country's, and the days off coming up: public holidays, and who is away and when they're back. The forecast and the weekly load count these as days nobody works." + DATA_NOTE,
|
|
20069
|
+
input: object({
|
|
20070
|
+
harbor: harborArg,
|
|
20071
|
+
days: number().int().min(1).max(366).default(56).describe("How far ahead to look, in days.")
|
|
20072
|
+
}),
|
|
20073
|
+
run: (ctx, input) => {
|
|
20074
|
+
const { data } = ctx;
|
|
20075
|
+
const today = todayISO(ctx.now);
|
|
20076
|
+
const until = plus$1(today, input.days);
|
|
20077
|
+
return {
|
|
20078
|
+
countries: workCalendar(data).countries.map((c) => ({
|
|
20079
|
+
code: c,
|
|
20080
|
+
name: countryName(c)
|
|
20081
|
+
})),
|
|
20082
|
+
people: data.people.filter((p) => countryCode(p.country)).map((p) => ({
|
|
20083
|
+
name: p.name,
|
|
20084
|
+
country: countryCode(p.country)
|
|
20085
|
+
})),
|
|
20086
|
+
holidays: harborHolidays(data, today, until).map((h) => ({
|
|
20087
|
+
date: h.date,
|
|
20088
|
+
name: h.names
|
|
20089
|
+
})),
|
|
20090
|
+
away: data.people.filter((p) => p.away?.since && p.away.until > today && p.away.since.slice(0, 10) <= until).map((p) => ({
|
|
20091
|
+
name: p.name,
|
|
20092
|
+
from: p.away.since.slice(0, 10),
|
|
20093
|
+
back: p.away.until
|
|
20094
|
+
})),
|
|
20095
|
+
known: COUNTRIES.map((c) => c.code)
|
|
20096
|
+
};
|
|
20097
|
+
}
|
|
20098
|
+
}),
|
|
19261
20099
|
tool({
|
|
19262
20100
|
name: "long_pole",
|
|
19263
20101
|
title: "What's holding this up",
|
|
19264
|
-
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,
|
|
19265
20103
|
input: object({
|
|
19266
20104
|
harbor: harborArg,
|
|
19267
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).")
|
|
@@ -19287,8 +20125,10 @@ const HARBOR_TOOLS = [
|
|
|
19287
20125
|
},
|
|
19288
20126
|
headline: poleHeadline(pole, name, held),
|
|
19289
20127
|
finish: pole.finish,
|
|
19290
|
-
|
|
19291
|
-
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) } : {},
|
|
19292
20132
|
...pole.via ? { via: {
|
|
19293
20133
|
project: pole.via.project.name,
|
|
19294
20134
|
finish: pole.via.finish
|
|
@@ -19324,7 +20164,12 @@ const HARBOR_TOOLS = [
|
|
|
19324
20164
|
try {
|
|
19325
20165
|
milestone = findOne(milestones.length ? milestones : data.milestones, input.of, "milestone");
|
|
19326
20166
|
} catch (inner) {
|
|
19327
|
-
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
|
+
}
|
|
19328
20173
|
throw inner;
|
|
19329
20174
|
}
|
|
19330
20175
|
return answer("milestone", milestone.name, milestonePole(milestone, data, ctx.now), "the milestone");
|
|
@@ -19332,6 +20177,26 @@ const HARBOR_TOOLS = [
|
|
|
19332
20177
|
return answer("project", project.name, projectPole(project, data.tasks, ctx.now), "the end date");
|
|
19333
20178
|
}
|
|
19334
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
|
+
}),
|
|
19335
20200
|
tool({
|
|
19336
20201
|
name: "shipping_forecast",
|
|
19337
20202
|
title: "The harbor forecast",
|
|
@@ -19566,7 +20431,8 @@ function pickData(s) {
|
|
|
19566
20431
|
checkIns: s.checkIns ?? NONE,
|
|
19567
20432
|
spendEntries: s.spendEntries ?? NONE,
|
|
19568
20433
|
reflections: s.reflections ?? NONE,
|
|
19569
|
-
priorityRules: s.priorityRules ?? null
|
|
20434
|
+
priorityRules: s.priorityRules ?? null,
|
|
20435
|
+
workCalendar: s.workCalendar ?? null
|
|
19570
20436
|
};
|
|
19571
20437
|
}
|
|
19572
20438
|
/** The document as sent to the server: normalized and without inline file data. */
|
|
@@ -20622,7 +21488,7 @@ function hierarchySlice({ get, write }) {
|
|
|
20622
21488
|
},
|
|
20623
21489
|
snapshotBaseline: (programId, reset = false) => {
|
|
20624
21490
|
const inProgram = new Set(get().projects.filter((p) => p.programId === programId).map((p) => p.id));
|
|
20625
|
-
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());
|
|
20626
21492
|
const byId = new Map(stamped.map((p) => [p.id, p]));
|
|
20627
21493
|
write({ projects: get().projects.map((p) => byId.get(p.id) ?? p) });
|
|
20628
21494
|
const program = get().programs.find((p) => p.id === programId);
|
|
@@ -20972,6 +21838,7 @@ function tasksSlice({ set, get, write }) {
|
|
|
20972
21838
|
...t,
|
|
20973
21839
|
id,
|
|
20974
21840
|
createdAt: stamp(),
|
|
21841
|
+
createdById: get().currentUserId || null,
|
|
20975
21842
|
completedAt: t.status === "done" ? t.completedAt ?? stamp() : null,
|
|
20976
21843
|
checklist: t.checklist ?? [],
|
|
20977
21844
|
...blockersPatch(blockerIds({
|
|
@@ -21845,6 +22712,19 @@ function peopleSpendSlice({ set, get, write }) {
|
|
|
21845
22712
|
...patch
|
|
21846
22713
|
} : p) });
|
|
21847
22714
|
},
|
|
22715
|
+
setHolidayCountries: (codes) => {
|
|
22716
|
+
const countries = [...new Set(codes.map((c) => countryCode(c)).filter((c) => c !== null))].slice(0, 12);
|
|
22717
|
+
write({ workCalendar: countries.length ? { countries } : null });
|
|
22718
|
+
},
|
|
22719
|
+
setPersonCountry: (id, code) => {
|
|
22720
|
+
const country = code ? countryCode(code) : null;
|
|
22721
|
+
if (code && !country) return false;
|
|
22722
|
+
write({ people: get().people.map((p) => p.id === id ? {
|
|
22723
|
+
...p,
|
|
22724
|
+
country
|
|
22725
|
+
} : p) });
|
|
22726
|
+
return true;
|
|
22727
|
+
},
|
|
21848
22728
|
addPerson: (p) => {
|
|
21849
22729
|
const id = uid("p");
|
|
21850
22730
|
const row = {
|
|
@@ -22028,6 +22908,7 @@ var init_people_spend = __esmMin((() => {
|
|
|
22028
22908
|
init_assignment();
|
|
22029
22909
|
init_spend();
|
|
22030
22910
|
init_utils();
|
|
22911
|
+
init_countries();
|
|
22031
22912
|
init_shared();
|
|
22032
22913
|
init_allocation();
|
|
22033
22914
|
}));
|
|
@@ -22153,7 +23034,7 @@ function pickCollections(doc, keys) {
|
|
|
22153
23034
|
function emptyRows() {
|
|
22154
23035
|
return Object.fromEntries(ROW_COLLECTIONS.map((k) => [k, []]));
|
|
22155
23036
|
}
|
|
22156
|
-
/** A collection as rows; `spendEntries`, `reflections` and `
|
|
23037
|
+
/** A collection as rows; `spendEntries`, `reflections`, `priorityRules` and `workCalendar` may be missing from older documents. */
|
|
22157
23038
|
function rowsOf(doc, key) {
|
|
22158
23039
|
if (isKeyed(key)) return KEYED[key].rows(doc[key]);
|
|
22159
23040
|
return doc[key] ?? [];
|
|
@@ -22237,7 +23118,8 @@ var init_changes = __esmMin((() => {
|
|
|
22237
23118
|
"checkIns",
|
|
22238
23119
|
"spendEntries",
|
|
22239
23120
|
"reflections",
|
|
22240
|
-
"priorityRules"
|
|
23121
|
+
"priorityRules",
|
|
23122
|
+
"workCalendar"
|
|
22241
23123
|
];
|
|
22242
23124
|
ROW_COLLECTIONS = [
|
|
22243
23125
|
"tasks",
|
|
@@ -22257,7 +23139,8 @@ var init_changes = __esmMin((() => {
|
|
|
22257
23139
|
"boardConfigs",
|
|
22258
23140
|
"ropes",
|
|
22259
23141
|
"readPingIds",
|
|
22260
|
-
"priorityRules"
|
|
23142
|
+
"priorityRules",
|
|
23143
|
+
"workCalendar"
|
|
22261
23144
|
];
|
|
22262
23145
|
KEYED = {
|
|
22263
23146
|
boardConfigs: {
|
|
@@ -22288,6 +23171,17 @@ var init_changes = __esmMin((() => {
|
|
|
22288
23171
|
const { id: _id, ...rules } = rows[0];
|
|
22289
23172
|
return rules;
|
|
22290
23173
|
}
|
|
23174
|
+
},
|
|
23175
|
+
workCalendar: {
|
|
23176
|
+
rows: (value) => value ? [{
|
|
23177
|
+
...value,
|
|
23178
|
+
id: "calendar"
|
|
23179
|
+
}] : [],
|
|
23180
|
+
value: (rows) => {
|
|
23181
|
+
if (!rows[0]) return null;
|
|
23182
|
+
const { id: _id, ...calendar } = rows[0];
|
|
23183
|
+
return calendar;
|
|
23184
|
+
}
|
|
22291
23185
|
}
|
|
22292
23186
|
};
|
|
22293
23187
|
isKeyed = (key) => key in KEYED;
|
|
@@ -24149,6 +25043,7 @@ var init_repo_sync = __esmMin((() => {
|
|
|
24149
25043
|
//#region src/tui/try.ts
|
|
24150
25044
|
init_format();
|
|
24151
25045
|
init_task_keys();
|
|
25046
|
+
init_tide();
|
|
24152
25047
|
init_view_only();
|
|
24153
25048
|
init_errors();
|
|
24154
25049
|
init_repo_sync();
|