@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/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
- /** Days from today to finishing, one per run; Infinity for a run that never does. */
3818
- function simulate(remaining, weekly, seed, pace = 1) {
3819
- const next = random(seed);
3820
- const out = [];
3821
- for (let run = 0; run < TRIALS; run++) {
3822
- let left = remaining;
3823
- let days = Infinity;
3824
- for (let week = 0; week < MAX_WEEKS; week++) {
3825
- const drawn = weekly[Math.floor(next() * weekly.length)];
3826
- const closed = pace === 1 ? drawn : Math.floor(drawn * pace) + (next() < drawn * pace % 1 ? 1 : 0);
3827
- if (closed >= left) {
3828
- const landed = Array.from({ length: closed }, () => 1 + Math.floor(next() * 7));
3829
- landed.sort((a, b) => a - b);
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
- * Per run, the fewest days the sounded tasks allow: each draws one of its
3841
- * answers at random, so a wide spread widens the forecast. A task already
3842
- * in play counts half its days; it's partly done.
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 soundingFloors(tasks, reflections, base, seed) {
3845
- const draws = [];
3846
- for (const t of tasks) {
3847
- if (t.status === "done") continue;
3848
- const days = soundingDraws(t, reflections);
3849
- if (days.length === 0) continue;
3850
- draws.push({
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
- floors,
3877
- sounded: draws.length
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
- * `pace` scales the crew's weekly counts (1 is as they are), for dry dock's
3882
- * "what if Sam joined" (src/lib/dry-dock.ts).
3883
- */
3884
- function tideForecast(project, tasks, now, opts = {}) {
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
- kind: "forecast",
3926
- remaining,
3927
- finished,
3928
- weeks: weekly.length,
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
- /** The projects with the current dates committed as their baseline; ones that already have one are left alone unless `reset`. */
4209
- function withBaseline(projects, reset = false) {
4918
+ /**
4919
+ * The projects with the current dates committed as their baseline; ones that
4920
+ * already have one are left alone unless `reset`. `at` is when (ISO), so work
4921
+ * added later reads as growth.
4922
+ */
4923
+ function withBaseline(projects, reset = false, at) {
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`), { reflections: data.reflections });
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: iso$2(addDays(origin, i * 7)),
8852
- due: 0
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
- return chainOf(lineTo(last, all, walk), walk, project.endDate.slice(0, 10), today);
18432
+ const undated = mine.filter((t) => !walk(t).finish).length;
18433
+ return {
18434
+ ...chainOf(lineTo(last, all, walk), walk, project.endDate.slice(0, 10), today),
18435
+ undated
18436
+ };
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, reflections) {
18229
- const tide = tideForecast(project, tasks, now, { reflections });
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.reflections),
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.reflections),
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. over is how many days the line finishes past the date held (a project's end, a task's or milestone's due date). For a milestone, via is the project it waits on that finishes last, and others are the rest with their slack." + DATA_NOTE,
20102
+ description: "The long pole behind a project, a task or a milestone: of all the task waits behind it, the one line that sets its date, first blocker to last. Each link has its due date, the soonest it can finish (its due date, later when what it waits on finishes after, today at the earliest for late work) and slack: days it can slip before it moves the next link, or the end; negative when it already does. daysSpare is how many days the line finishes before the date held (endDate for a project, dueDate for a task or milestone); negative is that many days over. undated counts open tasks left out for having no due date (note says so). For a milestone, via is the project it waits on that finishes last, and others are the rest with their slack." + DATA_NOTE,
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
- held: pole.target,
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)) throw new ToolError(`No project, task key or open milestone matches "${input.of.trim()}".`);
20167
+ if (inner instanceof ToolError && /^No milestone matches/.test(inner.message)) {
20168
+ const projects = data.projects.filter((x) => x.health !== "done");
20169
+ const there = [...projects.slice(0, 12).map((x) => x.key ? `${x.name} (${x.key})` : x.name), ...projects.length > 12 ? [`and ${projects.length - 12} more`] : []];
20170
+ const marks = milestones.slice(0, 6).map((m) => m.name);
20171
+ throw new ToolError(`No project, task key or open milestone matches "${input.of.trim()}".` + (there.length ? ` Open projects: ${there.join(", ")}.` : "") + (marks.length ? ` Open milestones: ${marks.join(", ")}.` : ""));
20172
+ }
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 `priorityRules` may be missing from older documents. */
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();