@harborppm/cli 0.58.0 → 0.59.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 +3 -2
- package/dist/app.mjs +709 -37
- package/lib/cli.mjs +110 -35
- package/lib/format.mjs +21 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -190,8 +190,9 @@ harbor import FILE [--project P] [--portfolio P] [--no-people]
|
|
|
190
190
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
191
191
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
192
192
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
193
|
-
harbor sound TASK [
|
|
193
|
+
harbor sound TASK [--answer DAYS|--ask NAMES|--settle DAYS] [--why TEXT]
|
|
194
194
|
harbor soundings [PROJECT]
|
|
195
|
+
harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
|
|
195
196
|
harbor limit PROJECT [COLUMN N|none]
|
|
196
197
|
harbor week [--next|--last|--week DATE] [--review]
|
|
197
198
|
harbor week TASK DAY [--hours N] | week TASK --off [DAY] | week --day-hours N
|
|
@@ -227,7 +228,7 @@ Agents on the crew: put an AI app on People with Add an agent and assign it task
|
|
|
227
228
|
|
|
228
229
|
`harbor limit RR` prints how many tasks each of a board's columns takes at once; `harbor limit RR doing 8` changes one (1 to 20, or `none` for no limit), if you own the harbor. `harbor projects --json` carries each board's `limits` too.
|
|
229
230
|
|
|
230
|
-
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first.
|
|
231
|
+
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK --answer 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first. `harbor ashore` lists the public holidays and time away in the next eight weeks; `--country US,GB` sets whose holidays the harbor takes and `--person NAME --country DE` one person's own, which the forecast and the weekly load then count as days off.
|
|
231
232
|
|
|
232
233
|
`harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux, which you may need to install). With `--json` it prints the forecast and stays quiet.
|
|
233
234
|
|
package/dist/app.mjs
CHANGED
|
@@ -2974,7 +2974,7 @@ const scale = (labels) => [
|
|
|
2974
2974
|
label: `${n} ${labels[n]}`,
|
|
2975
2975
|
value: n
|
|
2976
2976
|
}));
|
|
2977
|
-
const plus = (today, days) => format(addDays(parseIso(today), days), "yyyy-MM-dd");
|
|
2977
|
+
const plus$1 = (today, days) => format(addDays(parseIso(today), days), "yyyy-MM-dd");
|
|
2978
2978
|
/** The colour a new project or program takes: the first one its neighbours don't use. */
|
|
2979
2979
|
function nextSwatch(used) {
|
|
2980
2980
|
return SWATCHES.find((s) => !used.includes(s)) ?? SWATCHES[0];
|
|
@@ -3236,7 +3236,7 @@ function commitCreate(store, kind, v, ctx, today) {
|
|
|
3236
3236
|
ownerId: str("ownerId"),
|
|
3237
3237
|
programId,
|
|
3238
3238
|
startDate: today,
|
|
3239
|
-
endDate: str("endDate") ? clampEndToParent(str("endDate"), program.endDate, today) : reckoning ? reckonedEnd(reckoning, today) : clampEndToParent(plus(today, 60), program.endDate, today),
|
|
3239
|
+
endDate: str("endDate") ? clampEndToParent(str("endDate"), program.endDate, today) : reckoning ? reckonedEnd(reckoning, today) : clampEndToParent(plus$1(today, 60), program.endDate, today),
|
|
3240
3240
|
budget: typeof v.budget === "number" ? v.budget : 0
|
|
3241
3241
|
});
|
|
3242
3242
|
if (reckoning) applyReckoning(store.getState(), id, reckoning, today);
|
|
@@ -3252,7 +3252,7 @@ function commitCreate(store, kind, v, ctx, today) {
|
|
|
3252
3252
|
ownerId: str("ownerId"),
|
|
3253
3253
|
portfolioId,
|
|
3254
3254
|
startDate: today,
|
|
3255
|
-
endDate: str("endDate") || plus(today, 90)
|
|
3255
|
+
endDate: str("endDate") || plus$1(today, 90)
|
|
3256
3256
|
});
|
|
3257
3257
|
}
|
|
3258
3258
|
case "milestone":
|
|
@@ -3260,7 +3260,7 @@ function commitCreate(store, kind, v, ctx, today) {
|
|
|
3260
3260
|
return s.addMilestone({
|
|
3261
3261
|
programId: ctx.programId,
|
|
3262
3262
|
name: str("name"),
|
|
3263
|
-
dueDate: str("dueDate") || plus(today, 14)
|
|
3263
|
+
dueDate: str("dueDate") || plus$1(today, 14)
|
|
3264
3264
|
});
|
|
3265
3265
|
case "raid": {
|
|
3266
3266
|
const projectId = ctx.projectId ?? (v.projectId || null);
|
|
@@ -3707,14 +3707,19 @@ function soundingDraws(task, reflections) {
|
|
|
3707
3707
|
if (s.settledDays != null) return [s.settledDays];
|
|
3708
3708
|
return answersFor(reflections, task).map((a) => a.days);
|
|
3709
3709
|
}
|
|
3710
|
-
/**
|
|
3711
|
-
|
|
3710
|
+
/**
|
|
3711
|
+
* Calendar days from `from` until `days` working days have passed: Monday
|
|
3712
|
+
* to Friday, or the days `works` says (a person's holidays and time away,
|
|
3713
|
+
* src/lib/holidays.ts). Gives up after two years.
|
|
3714
|
+
*/
|
|
3715
|
+
function workingDaysAhead(days, from, works) {
|
|
3712
3716
|
let left = Math.ceil(days);
|
|
3713
3717
|
let d = 0;
|
|
3714
|
-
while (left > 0) {
|
|
3718
|
+
while (left > 0 && d < 730) {
|
|
3715
3719
|
d += 1;
|
|
3716
|
-
const
|
|
3717
|
-
|
|
3720
|
+
const at = addDays(from, d);
|
|
3721
|
+
const wd = at.getDay();
|
|
3722
|
+
if (works ? works(at) : wd !== 0 && wd !== 6) left -= 1;
|
|
3718
3723
|
}
|
|
3719
3724
|
return d;
|
|
3720
3725
|
}
|
|
@@ -3758,11 +3763,508 @@ var init_soundings = __esmMin((() => {
|
|
|
3758
3763
|
SOUNDING_MAX_DAYS = 250;
|
|
3759
3764
|
}));
|
|
3760
3765
|
|
|
3766
|
+
//#endregion
|
|
3767
|
+
//#region src/lib/countries.ts
|
|
3768
|
+
/** A country code Harbor has holidays for, upper-cased; null otherwise. */
|
|
3769
|
+
function countryCode(text) {
|
|
3770
|
+
if (!text) return null;
|
|
3771
|
+
const t = text.trim();
|
|
3772
|
+
const up = t.toUpperCase();
|
|
3773
|
+
if (KNOWN.has(up)) return up;
|
|
3774
|
+
if (up === "UK") return "GB";
|
|
3775
|
+
if (up === "USA") return "US";
|
|
3776
|
+
return COUNTRIES.find((c) => c.name.toLowerCase() === t.toLowerCase())?.code ?? null;
|
|
3777
|
+
}
|
|
3778
|
+
var COUNTRIES, KNOWN, countryName;
|
|
3779
|
+
var init_countries = __esmMin((() => {
|
|
3780
|
+
COUNTRIES = [
|
|
3781
|
+
{
|
|
3782
|
+
code: "AU",
|
|
3783
|
+
name: "Australia"
|
|
3784
|
+
},
|
|
3785
|
+
{
|
|
3786
|
+
code: "BR",
|
|
3787
|
+
name: "Brazil"
|
|
3788
|
+
},
|
|
3789
|
+
{
|
|
3790
|
+
code: "CA",
|
|
3791
|
+
name: "Canada"
|
|
3792
|
+
},
|
|
3793
|
+
{
|
|
3794
|
+
code: "FR",
|
|
3795
|
+
name: "France"
|
|
3796
|
+
},
|
|
3797
|
+
{
|
|
3798
|
+
code: "DE",
|
|
3799
|
+
name: "Germany"
|
|
3800
|
+
},
|
|
3801
|
+
{
|
|
3802
|
+
code: "IE",
|
|
3803
|
+
name: "Ireland"
|
|
3804
|
+
},
|
|
3805
|
+
{
|
|
3806
|
+
code: "IT",
|
|
3807
|
+
name: "Italy"
|
|
3808
|
+
},
|
|
3809
|
+
{
|
|
3810
|
+
code: "MX",
|
|
3811
|
+
name: "Mexico"
|
|
3812
|
+
},
|
|
3813
|
+
{
|
|
3814
|
+
code: "NL",
|
|
3815
|
+
name: "Netherlands"
|
|
3816
|
+
},
|
|
3817
|
+
{
|
|
3818
|
+
code: "NZ",
|
|
3819
|
+
name: "New Zealand"
|
|
3820
|
+
},
|
|
3821
|
+
{
|
|
3822
|
+
code: "ES",
|
|
3823
|
+
name: "Spain"
|
|
3824
|
+
},
|
|
3825
|
+
{
|
|
3826
|
+
code: "GB",
|
|
3827
|
+
name: "United Kingdom"
|
|
3828
|
+
},
|
|
3829
|
+
{
|
|
3830
|
+
code: "US",
|
|
3831
|
+
name: "United States"
|
|
3832
|
+
}
|
|
3833
|
+
];
|
|
3834
|
+
KNOWN = new Set(COUNTRIES.map((c) => c.code));
|
|
3835
|
+
countryName = (code) => COUNTRIES.find((c) => c.code === code)?.name ?? code;
|
|
3836
|
+
}));
|
|
3837
|
+
|
|
3838
|
+
//#endregion
|
|
3839
|
+
//#region src/lib/holidays.ts
|
|
3840
|
+
init_soundings();
|
|
3841
|
+
init_date_fns();
|
|
3842
|
+
init_format();
|
|
3843
|
+
init_countries();
|
|
3844
|
+
const iso$4 = (y, m, d) => todayISO(new Date(y, m - 1, d, 12));
|
|
3845
|
+
const weekday = (day) => (/* @__PURE__ */ new Date(`${day}T12:00:00`)).getDay();
|
|
3846
|
+
const plus = (day, n) => todayISO(addDays(/* @__PURE__ */ new Date(`${day}T12:00:00`), n));
|
|
3847
|
+
const isWeekend = (day) => {
|
|
3848
|
+
const wd = weekday(day);
|
|
3849
|
+
return wd === 0 || wd === 6;
|
|
3850
|
+
};
|
|
3851
|
+
/** Easter Sunday (the anonymous Gregorian algorithm). */
|
|
3852
|
+
function easter(year) {
|
|
3853
|
+
const a = year % 19;
|
|
3854
|
+
const b = Math.floor(year / 100);
|
|
3855
|
+
const c = year % 100;
|
|
3856
|
+
const d = Math.floor(b / 4);
|
|
3857
|
+
const e = b % 4;
|
|
3858
|
+
const f = Math.floor((b + 8) / 25);
|
|
3859
|
+
const g = Math.floor((b - f + 1) / 3);
|
|
3860
|
+
const h = (19 * a + b - d - g + 15) % 30;
|
|
3861
|
+
const i = Math.floor(c / 4);
|
|
3862
|
+
const k = c % 4;
|
|
3863
|
+
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
|
3864
|
+
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
|
3865
|
+
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
|
3866
|
+
const day = (h + l - 7 * m + 114) % 31 + 1;
|
|
3867
|
+
return iso$4(year, month, day);
|
|
3868
|
+
}
|
|
3869
|
+
/** The nth given weekday (0 Sunday) of a month; n = -1 for the last. */
|
|
3870
|
+
function nth(year, month, dow, n) {
|
|
3871
|
+
if (n > 0) {
|
|
3872
|
+
const first = iso$4(year, month, 1);
|
|
3873
|
+
const shift = (dow - weekday(first) + 7) % 7;
|
|
3874
|
+
return plus(first, shift + (n - 1) * 7);
|
|
3875
|
+
}
|
|
3876
|
+
const last = iso$4(year, month + 1, 0);
|
|
3877
|
+
return plus(last, -((weekday(last) - dow + 7) % 7));
|
|
3878
|
+
}
|
|
3879
|
+
/** The given weekday on or before a day. */
|
|
3880
|
+
const onOrBefore = (day, dow) => plus(day, -((weekday(day) - dow + 7) % 7));
|
|
3881
|
+
const fixed = (m, d) => (y) => iso$4(y, m, d);
|
|
3882
|
+
const fromEaster = (n) => (y) => plus(easter(y), n);
|
|
3883
|
+
/** New Zealand's Matariki, set by law for each year. */
|
|
3884
|
+
const MATARIKI = {
|
|
3885
|
+
2025: "2025-06-20",
|
|
3886
|
+
2026: "2026-07-10",
|
|
3887
|
+
2027: "2027-06-25",
|
|
3888
|
+
2028: "2028-07-14",
|
|
3889
|
+
2029: "2029-07-06",
|
|
3890
|
+
2030: "2030-06-21",
|
|
3891
|
+
2031: "2031-07-11",
|
|
3892
|
+
2032: "2032-07-02",
|
|
3893
|
+
2033: "2033-06-24",
|
|
3894
|
+
2034: "2034-07-07",
|
|
3895
|
+
2035: "2035-06-29"
|
|
3896
|
+
};
|
|
3897
|
+
const RULES = {
|
|
3898
|
+
US: [
|
|
3899
|
+
[
|
|
3900
|
+
"New Year's Day",
|
|
3901
|
+
fixed(1, 1),
|
|
3902
|
+
"us"
|
|
3903
|
+
],
|
|
3904
|
+
["Martin Luther King Jr. Day", (y) => nth(y, 1, 1, 3)],
|
|
3905
|
+
["Presidents' Day", (y) => nth(y, 2, 1, 3)],
|
|
3906
|
+
["Memorial Day", (y) => nth(y, 5, 1, -1)],
|
|
3907
|
+
[
|
|
3908
|
+
"Juneteenth",
|
|
3909
|
+
fixed(6, 19),
|
|
3910
|
+
"us"
|
|
3911
|
+
],
|
|
3912
|
+
[
|
|
3913
|
+
"Independence Day",
|
|
3914
|
+
fixed(7, 4),
|
|
3915
|
+
"us"
|
|
3916
|
+
],
|
|
3917
|
+
["Labor Day", (y) => nth(y, 9, 1, 1)],
|
|
3918
|
+
["Thanksgiving", (y) => nth(y, 11, 4, 4)],
|
|
3919
|
+
[
|
|
3920
|
+
"Christmas Day",
|
|
3921
|
+
fixed(12, 25),
|
|
3922
|
+
"us"
|
|
3923
|
+
]
|
|
3924
|
+
],
|
|
3925
|
+
CA: [
|
|
3926
|
+
[
|
|
3927
|
+
"New Year's Day",
|
|
3928
|
+
fixed(1, 1),
|
|
3929
|
+
"next"
|
|
3930
|
+
],
|
|
3931
|
+
["Good Friday", fromEaster(-2)],
|
|
3932
|
+
["Victoria Day", (y) => onOrBefore(iso$4(y, 5, 24), 1)],
|
|
3933
|
+
[
|
|
3934
|
+
"Canada Day",
|
|
3935
|
+
fixed(7, 1),
|
|
3936
|
+
"next"
|
|
3937
|
+
],
|
|
3938
|
+
["Labour Day", (y) => nth(y, 9, 1, 1)],
|
|
3939
|
+
["Thanksgiving", (y) => nth(y, 10, 1, 2)],
|
|
3940
|
+
[
|
|
3941
|
+
"Christmas Day",
|
|
3942
|
+
fixed(12, 25),
|
|
3943
|
+
"next"
|
|
3944
|
+
],
|
|
3945
|
+
[
|
|
3946
|
+
"Boxing Day",
|
|
3947
|
+
fixed(12, 26),
|
|
3948
|
+
"next"
|
|
3949
|
+
]
|
|
3950
|
+
],
|
|
3951
|
+
GB: [
|
|
3952
|
+
[
|
|
3953
|
+
"New Year's Day",
|
|
3954
|
+
fixed(1, 1),
|
|
3955
|
+
"next"
|
|
3956
|
+
],
|
|
3957
|
+
["Good Friday", fromEaster(-2)],
|
|
3958
|
+
["Easter Monday", fromEaster(1)],
|
|
3959
|
+
["Early May bank holiday", (y) => nth(y, 5, 1, 1)],
|
|
3960
|
+
["Spring bank holiday", (y) => nth(y, 5, 1, -1)],
|
|
3961
|
+
["Summer bank holiday", (y) => nth(y, 8, 1, -1)],
|
|
3962
|
+
[
|
|
3963
|
+
"Christmas Day",
|
|
3964
|
+
fixed(12, 25),
|
|
3965
|
+
"next"
|
|
3966
|
+
],
|
|
3967
|
+
[
|
|
3968
|
+
"Boxing Day",
|
|
3969
|
+
fixed(12, 26),
|
|
3970
|
+
"next"
|
|
3971
|
+
]
|
|
3972
|
+
],
|
|
3973
|
+
IE: [
|
|
3974
|
+
[
|
|
3975
|
+
"New Year's Day",
|
|
3976
|
+
fixed(1, 1),
|
|
3977
|
+
"next"
|
|
3978
|
+
],
|
|
3979
|
+
["St Brigid's Day", (y) => weekday(iso$4(y, 2, 1)) === 5 ? iso$4(y, 2, 1) : nth(y, 2, 1, 1)],
|
|
3980
|
+
[
|
|
3981
|
+
"St Patrick's Day",
|
|
3982
|
+
fixed(3, 17),
|
|
3983
|
+
"next"
|
|
3984
|
+
],
|
|
3985
|
+
["Easter Monday", fromEaster(1)],
|
|
3986
|
+
["May bank holiday", (y) => nth(y, 5, 1, 1)],
|
|
3987
|
+
["June bank holiday", (y) => nth(y, 6, 1, 1)],
|
|
3988
|
+
["August bank holiday", (y) => nth(y, 8, 1, 1)],
|
|
3989
|
+
["October bank holiday", (y) => nth(y, 10, 1, -1)],
|
|
3990
|
+
[
|
|
3991
|
+
"Christmas Day",
|
|
3992
|
+
fixed(12, 25),
|
|
3993
|
+
"next"
|
|
3994
|
+
],
|
|
3995
|
+
[
|
|
3996
|
+
"St Stephen's Day",
|
|
3997
|
+
fixed(12, 26),
|
|
3998
|
+
"next"
|
|
3999
|
+
]
|
|
4000
|
+
],
|
|
4001
|
+
AU: [
|
|
4002
|
+
[
|
|
4003
|
+
"New Year's Day",
|
|
4004
|
+
fixed(1, 1),
|
|
4005
|
+
"next"
|
|
4006
|
+
],
|
|
4007
|
+
[
|
|
4008
|
+
"Australia Day",
|
|
4009
|
+
fixed(1, 26),
|
|
4010
|
+
"next"
|
|
4011
|
+
],
|
|
4012
|
+
["Good Friday", fromEaster(-2)],
|
|
4013
|
+
["Easter Monday", fromEaster(1)],
|
|
4014
|
+
["Anzac Day", fixed(4, 25)],
|
|
4015
|
+
["King's Birthday", (y) => nth(y, 6, 1, 2)],
|
|
4016
|
+
[
|
|
4017
|
+
"Christmas Day",
|
|
4018
|
+
fixed(12, 25),
|
|
4019
|
+
"next"
|
|
4020
|
+
],
|
|
4021
|
+
[
|
|
4022
|
+
"Boxing Day",
|
|
4023
|
+
fixed(12, 26),
|
|
4024
|
+
"next"
|
|
4025
|
+
]
|
|
4026
|
+
],
|
|
4027
|
+
NZ: [
|
|
4028
|
+
[
|
|
4029
|
+
"New Year's Day",
|
|
4030
|
+
fixed(1, 1),
|
|
4031
|
+
"next"
|
|
4032
|
+
],
|
|
4033
|
+
[
|
|
4034
|
+
"Day after New Year's Day",
|
|
4035
|
+
fixed(1, 2),
|
|
4036
|
+
"next"
|
|
4037
|
+
],
|
|
4038
|
+
[
|
|
4039
|
+
"Waitangi Day",
|
|
4040
|
+
fixed(2, 6),
|
|
4041
|
+
"next"
|
|
4042
|
+
],
|
|
4043
|
+
["Good Friday", fromEaster(-2)],
|
|
4044
|
+
["Easter Monday", fromEaster(1)],
|
|
4045
|
+
[
|
|
4046
|
+
"Anzac Day",
|
|
4047
|
+
fixed(4, 25),
|
|
4048
|
+
"next"
|
|
4049
|
+
],
|
|
4050
|
+
["King's Birthday", (y) => nth(y, 6, 1, 1)],
|
|
4051
|
+
["Matariki", (y) => MATARIKI[y] ?? ""],
|
|
4052
|
+
["Labour Day", (y) => nth(y, 10, 1, 4)],
|
|
4053
|
+
[
|
|
4054
|
+
"Christmas Day",
|
|
4055
|
+
fixed(12, 25),
|
|
4056
|
+
"next"
|
|
4057
|
+
],
|
|
4058
|
+
[
|
|
4059
|
+
"Boxing Day",
|
|
4060
|
+
fixed(12, 26),
|
|
4061
|
+
"next"
|
|
4062
|
+
]
|
|
4063
|
+
],
|
|
4064
|
+
DE: [
|
|
4065
|
+
["Neujahr", fixed(1, 1)],
|
|
4066
|
+
["Karfreitag", fromEaster(-2)],
|
|
4067
|
+
["Ostermontag", fromEaster(1)],
|
|
4068
|
+
["Tag der Arbeit", fixed(5, 1)],
|
|
4069
|
+
["Christi Himmelfahrt", fromEaster(39)],
|
|
4070
|
+
["Pfingstmontag", fromEaster(50)],
|
|
4071
|
+
["Tag der Deutschen Einheit", fixed(10, 3)],
|
|
4072
|
+
["1. Weihnachtstag", fixed(12, 25)],
|
|
4073
|
+
["2. Weihnachtstag", fixed(12, 26)]
|
|
4074
|
+
],
|
|
4075
|
+
FR: [
|
|
4076
|
+
["Jour de l'an", fixed(1, 1)],
|
|
4077
|
+
["Lundi de Pâques", fromEaster(1)],
|
|
4078
|
+
["Fête du Travail", fixed(5, 1)],
|
|
4079
|
+
["Victoire 1945", fixed(5, 8)],
|
|
4080
|
+
["Ascension", fromEaster(39)],
|
|
4081
|
+
["Lundi de Pentecôte", fromEaster(50)],
|
|
4082
|
+
["Fête nationale", fixed(7, 14)],
|
|
4083
|
+
["Assomption", fixed(8, 15)],
|
|
4084
|
+
["Toussaint", fixed(11, 1)],
|
|
4085
|
+
["Armistice", fixed(11, 11)],
|
|
4086
|
+
["Noël", fixed(12, 25)]
|
|
4087
|
+
],
|
|
4088
|
+
NL: [
|
|
4089
|
+
["Nieuwjaarsdag", fixed(1, 1)],
|
|
4090
|
+
["Tweede paasdag", fromEaster(1)],
|
|
4091
|
+
["Koningsdag", (y) => weekday(iso$4(y, 4, 27)) === 0 ? iso$4(y, 4, 26) : iso$4(y, 4, 27)],
|
|
4092
|
+
["Hemelvaartsdag", fromEaster(39)],
|
|
4093
|
+
["Tweede pinksterdag", fromEaster(50)],
|
|
4094
|
+
["Eerste kerstdag", fixed(12, 25)],
|
|
4095
|
+
["Tweede kerstdag", fixed(12, 26)]
|
|
4096
|
+
],
|
|
4097
|
+
ES: [
|
|
4098
|
+
["Año Nuevo", fixed(1, 1)],
|
|
4099
|
+
["Epifanía", fixed(1, 6)],
|
|
4100
|
+
["Viernes Santo", fromEaster(-2)],
|
|
4101
|
+
["Fiesta del Trabajo", fixed(5, 1)],
|
|
4102
|
+
["Asunción", fixed(8, 15)],
|
|
4103
|
+
["Fiesta Nacional", fixed(10, 12)],
|
|
4104
|
+
["Todos los Santos", fixed(11, 1)],
|
|
4105
|
+
["Día de la Constitución", fixed(12, 6)],
|
|
4106
|
+
["Inmaculada Concepción", fixed(12, 8)],
|
|
4107
|
+
["Navidad", fixed(12, 25)]
|
|
4108
|
+
],
|
|
4109
|
+
IT: [
|
|
4110
|
+
["Capodanno", fixed(1, 1)],
|
|
4111
|
+
["Epifania", fixed(1, 6)],
|
|
4112
|
+
["Lunedì dell'Angelo", fromEaster(1)],
|
|
4113
|
+
["Festa della Liberazione", fixed(4, 25)],
|
|
4114
|
+
["Festa del Lavoro", fixed(5, 1)],
|
|
4115
|
+
["Festa della Repubblica", fixed(6, 2)],
|
|
4116
|
+
["Ferragosto", fixed(8, 15)],
|
|
4117
|
+
["Ognissanti", fixed(11, 1)],
|
|
4118
|
+
["Immacolata Concezione", fixed(12, 8)],
|
|
4119
|
+
["Natale", fixed(12, 25)],
|
|
4120
|
+
["Santo Stefano", fixed(12, 26)]
|
|
4121
|
+
],
|
|
4122
|
+
MX: [
|
|
4123
|
+
["Año Nuevo", fixed(1, 1)],
|
|
4124
|
+
["Día de la Constitución", (y) => nth(y, 2, 1, 1)],
|
|
4125
|
+
["Natalicio de Benito Juárez", (y) => nth(y, 3, 1, 3)],
|
|
4126
|
+
["Día del Trabajo", fixed(5, 1)],
|
|
4127
|
+
["Día de la Independencia", fixed(9, 16)],
|
|
4128
|
+
["Día de la Revolución", (y) => nth(y, 11, 1, 3)],
|
|
4129
|
+
["Navidad", fixed(12, 25)]
|
|
4130
|
+
],
|
|
4131
|
+
BR: [
|
|
4132
|
+
["Confraternização Universal", fixed(1, 1)],
|
|
4133
|
+
["Carnaval", fromEaster(-48)],
|
|
4134
|
+
["Carnaval", fromEaster(-47)],
|
|
4135
|
+
["Sexta-feira Santa", fromEaster(-2)],
|
|
4136
|
+
["Tiradentes", fixed(4, 21)],
|
|
4137
|
+
["Dia do Trabalho", fixed(5, 1)],
|
|
4138
|
+
["Corpus Christi", fromEaster(60)],
|
|
4139
|
+
["Independência", fixed(9, 7)],
|
|
4140
|
+
["Nossa Senhora Aparecida", fixed(10, 12)],
|
|
4141
|
+
["Finados", fixed(11, 2)],
|
|
4142
|
+
["Proclamação da República", fixed(11, 15)],
|
|
4143
|
+
["Consciência Negra", fixed(11, 20)],
|
|
4144
|
+
["Natal", fixed(12, 25)]
|
|
4145
|
+
]
|
|
4146
|
+
};
|
|
4147
|
+
const cache = /* @__PURE__ */ new Map();
|
|
4148
|
+
/** A country's public holidays in a year, by the day they're taken, oldest first. */
|
|
4149
|
+
function holidaysIn(country, year) {
|
|
4150
|
+
const key = `${country}|${year}`;
|
|
4151
|
+
const had = cache.get(key);
|
|
4152
|
+
if (had) return had;
|
|
4153
|
+
const days = (RULES[country] ?? []).map(([name, day, move = "none"]) => ({
|
|
4154
|
+
name,
|
|
4155
|
+
date: day(year),
|
|
4156
|
+
move
|
|
4157
|
+
})).filter((h) => h.date);
|
|
4158
|
+
const taken = new Set(days.filter((h) => !isWeekend(h.date)).map((h) => h.date));
|
|
4159
|
+
const out = [];
|
|
4160
|
+
for (const h of days) {
|
|
4161
|
+
let date = h.date;
|
|
4162
|
+
const wd = weekday(date);
|
|
4163
|
+
if (h.move === "us" && (wd === 0 || wd === 6)) date = plus(date, wd === 6 ? -1 : 1);
|
|
4164
|
+
else if (h.move === "next" && (wd === 0 || wd === 6)) {
|
|
4165
|
+
date = plus(date, 1);
|
|
4166
|
+
while (taken.has(date) || isWeekend(date)) date = plus(date, 1);
|
|
4167
|
+
taken.add(date);
|
|
4168
|
+
}
|
|
4169
|
+
out.push({
|
|
4170
|
+
date,
|
|
4171
|
+
name: h.name,
|
|
4172
|
+
country
|
|
4173
|
+
});
|
|
4174
|
+
}
|
|
4175
|
+
out.sort((a, b) => a.date.localeCompare(b.date));
|
|
4176
|
+
cache.set(key, out);
|
|
4177
|
+
return out;
|
|
4178
|
+
}
|
|
4179
|
+
/** Holidays in the given countries between two days, both included. */
|
|
4180
|
+
function holidaysBetween(countries, from, to) {
|
|
4181
|
+
const out = [];
|
|
4182
|
+
const y0 = Number(from.slice(0, 4));
|
|
4183
|
+
const y1 = Number(to.slice(0, 4));
|
|
4184
|
+
for (const c of countries) for (let y = y0; y <= y1; y++) for (const h of holidaysIn(c, y)) if (h.date >= from && h.date <= to) out.push(h);
|
|
4185
|
+
return out.sort((a, b) => a.date.localeCompare(b.date) || a.country.localeCompare(b.country));
|
|
4186
|
+
}
|
|
4187
|
+
/** Days someone is away: from the handover day up to the day before they're back. */
|
|
4188
|
+
function awayDays(person) {
|
|
4189
|
+
const a = person.away;
|
|
4190
|
+
if (!a?.until || !a.since) return null;
|
|
4191
|
+
return [a.since.slice(0, 10), plus(a.until, -1)];
|
|
4192
|
+
}
|
|
4193
|
+
/** The harbor's calendar of who works when; see the top of this file. */
|
|
4194
|
+
function workCalendar(data) {
|
|
4195
|
+
const harbor = (data.workCalendar?.countries ?? []).filter((c) => countryCode(c) === c);
|
|
4196
|
+
const people = new Map(data.people.map((p) => [p.id, p]));
|
|
4197
|
+
const own = (id) => {
|
|
4198
|
+
const c = id ? countryCode(people.get(id)?.country) : null;
|
|
4199
|
+
return c ? [c] : harbor;
|
|
4200
|
+
};
|
|
4201
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
4202
|
+
const holidayOn = (countries, day) => {
|
|
4203
|
+
const year = Number(day.slice(0, 4));
|
|
4204
|
+
for (const c of countries) {
|
|
4205
|
+
const k = `${c}|${year}`;
|
|
4206
|
+
let days = byDay.get(k);
|
|
4207
|
+
if (!days) {
|
|
4208
|
+
days = new Map(holidaysIn(c, year).map((h) => [h.date, h]));
|
|
4209
|
+
byDay.set(k, days);
|
|
4210
|
+
}
|
|
4211
|
+
const h = days.get(day);
|
|
4212
|
+
if (h) return h;
|
|
4213
|
+
}
|
|
4214
|
+
return null;
|
|
4215
|
+
};
|
|
4216
|
+
const offOn = (personId, day) => {
|
|
4217
|
+
if (isWeekend(day)) return null;
|
|
4218
|
+
const person = personId ? people.get(personId) : void 0;
|
|
4219
|
+
const away = person ? awayDays(person) : null;
|
|
4220
|
+
if (away && day >= away[0] && day <= away[1]) return { kind: "away" };
|
|
4221
|
+
const h = holidayOn(own(personId), day);
|
|
4222
|
+
return h ? {
|
|
4223
|
+
kind: "holiday",
|
|
4224
|
+
name: h.name,
|
|
4225
|
+
country: h.country
|
|
4226
|
+
} : null;
|
|
4227
|
+
};
|
|
4228
|
+
return {
|
|
4229
|
+
countries: harbor,
|
|
4230
|
+
countriesOf: own,
|
|
4231
|
+
offOn,
|
|
4232
|
+
works: (personId, day) => !isWeekend(day) && offOn(personId, day) === null,
|
|
4233
|
+
active: harbor.length > 0 || data.people.some((p) => countryCode(p.country) || awayDays(p) !== null)
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
/** A calendar where nobody has holidays or time away: Monday to Friday. */
|
|
4237
|
+
const PLAIN_CALENDAR = workCalendar({ people: [] });
|
|
4238
|
+
/**
|
|
4239
|
+
* The public holidays anyone in the harbor takes between two days, one
|
|
4240
|
+
* entry a day: "Christmas Day (GB, US)". For views that aren't one person's,
|
|
4241
|
+
* like the timeline.
|
|
4242
|
+
*/
|
|
4243
|
+
function harborHolidays(data, from, to) {
|
|
4244
|
+
const countries = new Set(workCalendar(data).countries);
|
|
4245
|
+
for (const p of data.people) {
|
|
4246
|
+
const c = countryCode(p.country);
|
|
4247
|
+
if (c) countries.add(c);
|
|
4248
|
+
}
|
|
4249
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
4250
|
+
for (const h of holidaysBetween([...countries], from, to)) {
|
|
4251
|
+
if (isWeekend(h.date)) continue;
|
|
4252
|
+
byDay.set(h.date, [...byDay.get(h.date) ?? [], h]);
|
|
4253
|
+
}
|
|
4254
|
+
return [...byDay].map(([date, hs]) => {
|
|
4255
|
+
const names = [...new Set(hs.map((h) => h.name))];
|
|
4256
|
+
const codes = hs.map((h) => h.country).join(", ");
|
|
4257
|
+
return {
|
|
4258
|
+
date,
|
|
4259
|
+
names: `${names.join(" / ")} (${codes})`
|
|
4260
|
+
};
|
|
4261
|
+
});
|
|
4262
|
+
}
|
|
4263
|
+
|
|
3761
4264
|
//#endregion
|
|
3762
4265
|
//#region src/lib/tide.ts
|
|
3763
4266
|
init_date_fns();
|
|
3764
4267
|
init_format();
|
|
3765
|
-
init_soundings();
|
|
3766
4268
|
/**
|
|
3767
4269
|
* The tide forecast: when a project will likely finish, from how fast its
|
|
3768
4270
|
* crew has actually closed tasks. Each of a thousand runs replays the coming
|
|
@@ -3815,7 +4317,7 @@ function random(seed) {
|
|
|
3815
4317
|
};
|
|
3816
4318
|
}
|
|
3817
4319
|
/** Days from today to finishing, one per run; Infinity for a run that never does. */
|
|
3818
|
-
function simulate(remaining, weekly, seed, pace = 1) {
|
|
4320
|
+
function simulate(remaining, weekly, seed, pace = 1, shares = null) {
|
|
3819
4321
|
const next = random(seed);
|
|
3820
4322
|
const out = [];
|
|
3821
4323
|
for (let run = 0; run < TRIALS; run++) {
|
|
@@ -3823,7 +4325,12 @@ function simulate(remaining, weekly, seed, pace = 1) {
|
|
|
3823
4325
|
let days = Infinity;
|
|
3824
4326
|
for (let week = 0; week < MAX_WEEKS; week++) {
|
|
3825
4327
|
const drawn = weekly[Math.floor(next() * weekly.length)];
|
|
3826
|
-
|
|
4328
|
+
let closed = pace === 1 ? drawn : Math.floor(drawn * pace) + (next() < drawn * pace % 1 ? 1 : 0);
|
|
4329
|
+
const share = shares ? shares(week) : 1;
|
|
4330
|
+
if (share < 1 && closed > 0) {
|
|
4331
|
+
const scaled = closed * share;
|
|
4332
|
+
closed = Math.floor(scaled) + (next() < scaled - Math.floor(scaled) ? 1 : 0);
|
|
4333
|
+
}
|
|
3827
4334
|
if (closed >= left) {
|
|
3828
4335
|
const landed = Array.from({ length: closed }, () => 1 + Math.floor(next() * 7));
|
|
3829
4336
|
landed.sort((a, b) => a - b);
|
|
@@ -3841,7 +4348,7 @@ function simulate(remaining, weekly, seed, pace = 1) {
|
|
|
3841
4348
|
* answers at random, so a wide spread widens the forecast. A task already
|
|
3842
4349
|
* in play counts half its days; it's partly done.
|
|
3843
4350
|
*/
|
|
3844
|
-
function soundingFloors(tasks, reflections, base, seed) {
|
|
4351
|
+
function soundingFloors(tasks, reflections, base, seed, calendar) {
|
|
3845
4352
|
const draws = [];
|
|
3846
4353
|
for (const t of tasks) {
|
|
3847
4354
|
if (t.status === "done") continue;
|
|
@@ -3849,7 +4356,8 @@ function soundingFloors(tasks, reflections, base, seed) {
|
|
|
3849
4356
|
if (days.length === 0) continue;
|
|
3850
4357
|
draws.push({
|
|
3851
4358
|
days,
|
|
3852
|
-
share: t.status === "doing" || t.status === "review" ? .5 : 1
|
|
4359
|
+
share: t.status === "doing" || t.status === "review" ? .5 : 1,
|
|
4360
|
+
who: t.assigneeId
|
|
3853
4361
|
});
|
|
3854
4362
|
}
|
|
3855
4363
|
if (draws.length === 0) return {
|
|
@@ -3858,9 +4366,13 @@ function soundingFloors(tasks, reflections, base, seed) {
|
|
|
3858
4366
|
};
|
|
3859
4367
|
const next = random(seed);
|
|
3860
4368
|
const memo = /* @__PURE__ */ new Map();
|
|
3861
|
-
const ahead = (d) => {
|
|
3862
|
-
|
|
3863
|
-
|
|
4369
|
+
const ahead = (d, who) => {
|
|
4370
|
+
const k = `${who}|${d}`;
|
|
4371
|
+
let v = memo.get(k);
|
|
4372
|
+
if (v === void 0) {
|
|
4373
|
+
const works = calendar?.active ? (at) => calendar.works(who, todayISO(at)) : void 0;
|
|
4374
|
+
memo.set(k, v = workingDaysAhead(d, base, works));
|
|
4375
|
+
}
|
|
3864
4376
|
return v;
|
|
3865
4377
|
};
|
|
3866
4378
|
const floors = [];
|
|
@@ -3868,7 +4380,7 @@ function soundingFloors(tasks, reflections, base, seed) {
|
|
|
3868
4380
|
let most = 0;
|
|
3869
4381
|
for (const x of draws) {
|
|
3870
4382
|
const d = x.days[Math.floor(next() * x.days.length)] * x.share;
|
|
3871
|
-
most = Math.max(most, ahead(d));
|
|
4383
|
+
most = Math.max(most, ahead(d, x.who));
|
|
3872
4384
|
}
|
|
3873
4385
|
floors.push(most);
|
|
3874
4386
|
}
|
|
@@ -3878,6 +4390,79 @@ function soundingFloors(tasks, reflections, base, seed) {
|
|
|
3878
4390
|
};
|
|
3879
4391
|
}
|
|
3880
4392
|
/**
|
|
4393
|
+
* Each coming week's share of the crew's usual working days, from day 1
|
|
4394
|
+
* (tomorrow): who owns the open tasks, less their holidays and time away.
|
|
4395
|
+
* Null when the calendar has nothing in it, so the forecast reads as before.
|
|
4396
|
+
*/
|
|
4397
|
+
function weekShares(tasks, base, calendar) {
|
|
4398
|
+
const crew = [...new Set(tasks.filter((t) => t.status !== "done").map((t) => t.assigneeId))].filter((id) => id !== null);
|
|
4399
|
+
const people = crew.length ? crew : [null];
|
|
4400
|
+
if (!calendar?.active) return {
|
|
4401
|
+
shares: null,
|
|
4402
|
+
crew: people
|
|
4403
|
+
};
|
|
4404
|
+
const known = [];
|
|
4405
|
+
const share = (week) => {
|
|
4406
|
+
const had = known[week];
|
|
4407
|
+
if (had !== void 0) return had;
|
|
4408
|
+
let usual = 0;
|
|
4409
|
+
let working = 0;
|
|
4410
|
+
for (let d = week * 7 + 1; d <= week * 7 + 7; d++) {
|
|
4411
|
+
const day = todayISO(addDays(base, d));
|
|
4412
|
+
const wd = addDays(base, d).getDay();
|
|
4413
|
+
if (wd === 0 || wd === 6) continue;
|
|
4414
|
+
for (const p of people) {
|
|
4415
|
+
usual++;
|
|
4416
|
+
if (calendar.works(p, day)) working++;
|
|
4417
|
+
}
|
|
4418
|
+
}
|
|
4419
|
+
return known[week] = usual ? working / usual : 1;
|
|
4420
|
+
};
|
|
4421
|
+
return {
|
|
4422
|
+
shares: share,
|
|
4423
|
+
crew: people
|
|
4424
|
+
};
|
|
4425
|
+
}
|
|
4426
|
+
/** The holidays and away days the crew has between tomorrow and `until`. */
|
|
4427
|
+
function ashoreUntil(calendar, crew, base, until) {
|
|
4428
|
+
const holidays = [];
|
|
4429
|
+
let awayDays = 0;
|
|
4430
|
+
if (!calendar?.active || !Number.isFinite(until)) return {
|
|
4431
|
+
holidays,
|
|
4432
|
+
awayDays
|
|
4433
|
+
};
|
|
4434
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4435
|
+
for (let d = 1; d <= until; d++) {
|
|
4436
|
+
const day = todayISO(addDays(base, d));
|
|
4437
|
+
for (const p of crew) {
|
|
4438
|
+
const off = calendar.offOn(p, day);
|
|
4439
|
+
if (off?.kind === "away") awayDays++;
|
|
4440
|
+
else if (off?.kind === "holiday" && !seen.has(`${day}|${off.country}`)) {
|
|
4441
|
+
seen.add(`${day}|${off.country}`);
|
|
4442
|
+
holidays.push({
|
|
4443
|
+
date: day,
|
|
4444
|
+
name: off.name,
|
|
4445
|
+
country: off.country
|
|
4446
|
+
});
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
return {
|
|
4451
|
+
holidays,
|
|
4452
|
+
awayDays
|
|
4453
|
+
};
|
|
4454
|
+
}
|
|
4455
|
+
/** What the forecast reads beyond the tasks: soundings and the work calendar. */
|
|
4456
|
+
function tideOptions(data) {
|
|
4457
|
+
return {
|
|
4458
|
+
reflections: data.reflections,
|
|
4459
|
+
calendar: workCalendar({
|
|
4460
|
+
people: data.people ?? [],
|
|
4461
|
+
workCalendar: data.workCalendar
|
|
4462
|
+
})
|
|
4463
|
+
};
|
|
4464
|
+
}
|
|
4465
|
+
/**
|
|
3881
4466
|
* `pace` scales the crew's weekly counts (1 is as they are), for dry dock's
|
|
3882
4467
|
* "what if Sam joined" (src/lib/dry-dock.ts).
|
|
3883
4468
|
*/
|
|
@@ -3896,7 +4481,8 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
3896
4481
|
};
|
|
3897
4482
|
const today = todayISO(now);
|
|
3898
4483
|
const base = noon$2(today);
|
|
3899
|
-
const
|
|
4484
|
+
const { shares, crew } = weekShares(tasks, base, opts.calendar);
|
|
4485
|
+
const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1), shares);
|
|
3900
4486
|
const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
|
|
3901
4487
|
let lastDue = null;
|
|
3902
4488
|
for (const t of tasks) {
|
|
@@ -3904,7 +4490,7 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
3904
4490
|
if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
|
|
3905
4491
|
}
|
|
3906
4492
|
const floor = lastDue ? differenceInCalendarDays(noon$2(lastDue.dueDate), base) : 0;
|
|
3907
|
-
const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`));
|
|
4493
|
+
const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`), opts.calendar);
|
|
3908
4494
|
const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
|
|
3909
4495
|
const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
|
|
3910
4496
|
const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
|
|
@@ -3934,7 +4520,8 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
3934
4520
|
curve,
|
|
3935
4521
|
rough: finished < ROUGH_BELOW,
|
|
3936
4522
|
heldBy,
|
|
3937
|
-
sounded: sounded.sounded
|
|
4523
|
+
sounded: sounded.sounded,
|
|
4524
|
+
ashore: ashoreUntil(opts.calendar, crew, base, at(.85))
|
|
3938
4525
|
};
|
|
3939
4526
|
}
|
|
3940
4527
|
/** "15%", with "under 5%" and "over 95%" at the ends, where a thousand runs can't be that sure. */
|
|
@@ -4689,9 +5276,10 @@ function slipHeadline(cost) {
|
|
|
4689
5276
|
*/
|
|
4690
5277
|
function programSlipCost(data, programId, now) {
|
|
4691
5278
|
let sum = 0;
|
|
5279
|
+
const opts = tideOptions(data);
|
|
4692
5280
|
for (const p of data.projects) {
|
|
4693
5281
|
if (p.programId !== programId || p.budget <= 0 || p.health === "done") continue;
|
|
4694
|
-
const tide = tideForecast(p, tasksOf(data.tasks, p.id), now);
|
|
5282
|
+
const tide = tideForecast(p, tasksOf(data.tasks, p.id), now, opts);
|
|
4695
5283
|
sum += slipCost(p, tide, spendEntriesOf(data, p.id), now)?.likely?.cost ?? 0;
|
|
4696
5284
|
}
|
|
4697
5285
|
return sum;
|
|
@@ -4966,7 +5554,7 @@ function filterTasks(tasks, f) {
|
|
|
4966
5554
|
*/
|
|
4967
5555
|
function tideLine(data, project, today) {
|
|
4968
5556
|
const tasks = tasksOf(data.tasks, project.id);
|
|
4969
|
-
const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`),
|
|
5557
|
+
const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`), tideOptions(data));
|
|
4970
5558
|
if (tide.kind === "no-pace") return {
|
|
4971
5559
|
text: "No forecast yet: nothing finished in the last few weeks",
|
|
4972
5560
|
late: false
|
|
@@ -5373,7 +5961,7 @@ function keepText(keep, now) {
|
|
|
5373
5961
|
*/
|
|
5374
5962
|
function slipLine(data, project, today) {
|
|
5375
5963
|
const now = parseIso(`${today}T12:00:00`);
|
|
5376
|
-
const tide = tideForecast(project, tasksOf(data.tasks, project.id), now);
|
|
5964
|
+
const tide = tideForecast(project, tasksOf(data.tasks, project.id), now, tideOptions(data));
|
|
5377
5965
|
const cost = slipCost(project, tide, spendEntriesOf(data, project.id), now);
|
|
5378
5966
|
if (!cost?.likely || cost.likely.daysLate <= 0) return null;
|
|
5379
5967
|
return slipHeadline(cost);
|
|
@@ -8843,14 +9431,28 @@ function InboxView({ active, width, height, go }) {
|
|
|
8843
9431
|
//#endregion
|
|
8844
9432
|
//#region src/lib/load.ts
|
|
8845
9433
|
/** 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) {
|
|
9434
|
+
function weeklyLoad(tasks, personId, now, weeks = 6, calendar) {
|
|
8847
9435
|
const mine = tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
8848
9436
|
const today = iso$2(now);
|
|
8849
9437
|
const origin = startOfWeek(now, { weekStartsOn: 1 });
|
|
8850
|
-
const buckets = Array.from({ length: weeks }, (_, i) =>
|
|
8851
|
-
start
|
|
8852
|
-
|
|
8853
|
-
|
|
9438
|
+
const buckets = Array.from({ length: weeks }, (_, i) => {
|
|
9439
|
+
const start = iso$2(addDays(origin, i * 7));
|
|
9440
|
+
const off = [];
|
|
9441
|
+
if (calendar?.active) for (let d = 0; d < 5; d++) {
|
|
9442
|
+
const date = iso$2(addDays(origin, i * 7 + d));
|
|
9443
|
+
const why = calendar.offOn(personId, date);
|
|
9444
|
+
if (why) off.push({
|
|
9445
|
+
date,
|
|
9446
|
+
why
|
|
9447
|
+
});
|
|
9448
|
+
}
|
|
9449
|
+
return {
|
|
9450
|
+
start,
|
|
9451
|
+
due: 0,
|
|
9452
|
+
workDays: 5 - off.length,
|
|
9453
|
+
off
|
|
9454
|
+
};
|
|
9455
|
+
});
|
|
8854
9456
|
let overdue = 0;
|
|
8855
9457
|
let undated = 0;
|
|
8856
9458
|
for (const t of mine) {
|
|
@@ -18225,8 +18827,8 @@ const STATUSES = [
|
|
|
18225
18827
|
* for an agent: dates for half, 85% and 95% of the runs, or why there's none.
|
|
18226
18828
|
* Given the spend ledger, also what each week late costs (src/lib/slip-cost.ts).
|
|
18227
18829
|
*/
|
|
18228
|
-
function finishForecast(project, tasks, now, spend,
|
|
18229
|
-
const tide = tideForecast(project, tasks, now,
|
|
18830
|
+
function finishForecast(project, tasks, now, spend, data) {
|
|
18831
|
+
const tide = tideForecast(project, tasks, now, data ? tideOptions(data) : {});
|
|
18230
18832
|
if (tide.kind !== "forecast") return { none: tideQuiet(tide) };
|
|
18231
18833
|
const lastDue = dueAfterFinish(tide);
|
|
18232
18834
|
const dueLater = lastDue ? { lastDue: {
|
|
@@ -18253,6 +18855,14 @@ function finishForecast(project, tasks, now, spend, reflections) {
|
|
|
18253
18855
|
basedOn: `${tide.finished} task${tide.finished === 1 ? "" : "s"} finished in the last ${tide.weeks} week${tide.weeks === 1 ? "" : "s"}`,
|
|
18254
18856
|
...tide.rough ? { rough: true } : {},
|
|
18255
18857
|
...tide.sounded ? { soundedTasks: tide.sounded } : {},
|
|
18858
|
+
...tide.ashore.holidays.length || tide.ashore.awayDays ? { timeAshore: {
|
|
18859
|
+
holidays: tide.ashore.holidays.map((h) => ({
|
|
18860
|
+
date: h.date,
|
|
18861
|
+
name: h.name,
|
|
18862
|
+
country: h.country
|
|
18863
|
+
})),
|
|
18864
|
+
awayDays: tide.ashore.awayDays
|
|
18865
|
+
} } : {},
|
|
18256
18866
|
...tide.weeks < 2 ? { shortHistory: true } : {},
|
|
18257
18867
|
...dueLater,
|
|
18258
18868
|
...slip
|
|
@@ -18684,7 +19294,7 @@ const HARBOR_TOOLS = [
|
|
|
18684
19294
|
done: c.done,
|
|
18685
19295
|
late: c.late
|
|
18686
19296
|
},
|
|
18687
|
-
finish: finishForecast(p, tasksOf(data.tasks, p.id), ctx.now, spendBy.get(p.id) ?? [], data
|
|
19297
|
+
finish: finishForecast(p, tasksOf(data.tasks, p.id), ctx.now, spendBy.get(p.id) ?? [], data),
|
|
18688
19298
|
...limitsOf(data, p.id),
|
|
18689
19299
|
...hillOf(p, today),
|
|
18690
19300
|
...publicRoadmap(ctx, p.id)
|
|
@@ -18755,7 +19365,7 @@ const HARBOR_TOOLS = [
|
|
|
18755
19365
|
waitsOn,
|
|
18756
19366
|
milestones,
|
|
18757
19367
|
openRaid: raid,
|
|
18758
|
-
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data
|
|
19368
|
+
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
|
|
18759
19369
|
...charterOf(p, tasks),
|
|
18760
19370
|
...notesOf(ctx, p),
|
|
18761
19371
|
...hillOf(p, today),
|
|
@@ -19258,6 +19868,40 @@ const HARBOR_TOOLS = [
|
|
|
19258
19868
|
};
|
|
19259
19869
|
}
|
|
19260
19870
|
}),
|
|
19871
|
+
tool({
|
|
19872
|
+
name: "time_ashore",
|
|
19873
|
+
title: "Time ashore",
|
|
19874
|
+
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,
|
|
19875
|
+
input: object({
|
|
19876
|
+
harbor: harborArg,
|
|
19877
|
+
days: number().int().min(1).max(366).default(56).describe("How far ahead to look, in days.")
|
|
19878
|
+
}),
|
|
19879
|
+
run: (ctx, input) => {
|
|
19880
|
+
const { data } = ctx;
|
|
19881
|
+
const today = todayISO(ctx.now);
|
|
19882
|
+
const until = plus(today, input.days);
|
|
19883
|
+
return {
|
|
19884
|
+
countries: workCalendar(data).countries.map((c) => ({
|
|
19885
|
+
code: c,
|
|
19886
|
+
name: countryName(c)
|
|
19887
|
+
})),
|
|
19888
|
+
people: data.people.filter((p) => countryCode(p.country)).map((p) => ({
|
|
19889
|
+
name: p.name,
|
|
19890
|
+
country: countryCode(p.country)
|
|
19891
|
+
})),
|
|
19892
|
+
holidays: harborHolidays(data, today, until).map((h) => ({
|
|
19893
|
+
date: h.date,
|
|
19894
|
+
name: h.names
|
|
19895
|
+
})),
|
|
19896
|
+
away: data.people.filter((p) => p.away?.since && p.away.until > today && p.away.since.slice(0, 10) <= until).map((p) => ({
|
|
19897
|
+
name: p.name,
|
|
19898
|
+
from: p.away.since.slice(0, 10),
|
|
19899
|
+
back: p.away.until
|
|
19900
|
+
})),
|
|
19901
|
+
known: COUNTRIES.map((c) => c.code)
|
|
19902
|
+
};
|
|
19903
|
+
}
|
|
19904
|
+
}),
|
|
19261
19905
|
tool({
|
|
19262
19906
|
name: "long_pole",
|
|
19263
19907
|
title: "What's holding this up",
|
|
@@ -19566,7 +20210,8 @@ function pickData(s) {
|
|
|
19566
20210
|
checkIns: s.checkIns ?? NONE,
|
|
19567
20211
|
spendEntries: s.spendEntries ?? NONE,
|
|
19568
20212
|
reflections: s.reflections ?? NONE,
|
|
19569
|
-
priorityRules: s.priorityRules ?? null
|
|
20213
|
+
priorityRules: s.priorityRules ?? null,
|
|
20214
|
+
workCalendar: s.workCalendar ?? null
|
|
19570
20215
|
};
|
|
19571
20216
|
}
|
|
19572
20217
|
/** The document as sent to the server: normalized and without inline file data. */
|
|
@@ -21845,6 +22490,19 @@ function peopleSpendSlice({ set, get, write }) {
|
|
|
21845
22490
|
...patch
|
|
21846
22491
|
} : p) });
|
|
21847
22492
|
},
|
|
22493
|
+
setHolidayCountries: (codes) => {
|
|
22494
|
+
const countries = [...new Set(codes.map((c) => countryCode(c)).filter((c) => c !== null))].slice(0, 12);
|
|
22495
|
+
write({ workCalendar: countries.length ? { countries } : null });
|
|
22496
|
+
},
|
|
22497
|
+
setPersonCountry: (id, code) => {
|
|
22498
|
+
const country = code ? countryCode(code) : null;
|
|
22499
|
+
if (code && !country) return false;
|
|
22500
|
+
write({ people: get().people.map((p) => p.id === id ? {
|
|
22501
|
+
...p,
|
|
22502
|
+
country
|
|
22503
|
+
} : p) });
|
|
22504
|
+
return true;
|
|
22505
|
+
},
|
|
21848
22506
|
addPerson: (p) => {
|
|
21849
22507
|
const id = uid("p");
|
|
21850
22508
|
const row = {
|
|
@@ -22028,6 +22686,7 @@ var init_people_spend = __esmMin((() => {
|
|
|
22028
22686
|
init_assignment();
|
|
22029
22687
|
init_spend();
|
|
22030
22688
|
init_utils();
|
|
22689
|
+
init_countries();
|
|
22031
22690
|
init_shared();
|
|
22032
22691
|
init_allocation();
|
|
22033
22692
|
}));
|
|
@@ -22153,7 +22812,7 @@ function pickCollections(doc, keys) {
|
|
|
22153
22812
|
function emptyRows() {
|
|
22154
22813
|
return Object.fromEntries(ROW_COLLECTIONS.map((k) => [k, []]));
|
|
22155
22814
|
}
|
|
22156
|
-
/** A collection as rows; `spendEntries`, `reflections` and `
|
|
22815
|
+
/** A collection as rows; `spendEntries`, `reflections`, `priorityRules` and `workCalendar` may be missing from older documents. */
|
|
22157
22816
|
function rowsOf(doc, key) {
|
|
22158
22817
|
if (isKeyed(key)) return KEYED[key].rows(doc[key]);
|
|
22159
22818
|
return doc[key] ?? [];
|
|
@@ -22237,7 +22896,8 @@ var init_changes = __esmMin((() => {
|
|
|
22237
22896
|
"checkIns",
|
|
22238
22897
|
"spendEntries",
|
|
22239
22898
|
"reflections",
|
|
22240
|
-
"priorityRules"
|
|
22899
|
+
"priorityRules",
|
|
22900
|
+
"workCalendar"
|
|
22241
22901
|
];
|
|
22242
22902
|
ROW_COLLECTIONS = [
|
|
22243
22903
|
"tasks",
|
|
@@ -22257,7 +22917,8 @@ var init_changes = __esmMin((() => {
|
|
|
22257
22917
|
"boardConfigs",
|
|
22258
22918
|
"ropes",
|
|
22259
22919
|
"readPingIds",
|
|
22260
|
-
"priorityRules"
|
|
22920
|
+
"priorityRules",
|
|
22921
|
+
"workCalendar"
|
|
22261
22922
|
];
|
|
22262
22923
|
KEYED = {
|
|
22263
22924
|
boardConfigs: {
|
|
@@ -22288,6 +22949,17 @@ var init_changes = __esmMin((() => {
|
|
|
22288
22949
|
const { id: _id, ...rules } = rows[0];
|
|
22289
22950
|
return rules;
|
|
22290
22951
|
}
|
|
22952
|
+
},
|
|
22953
|
+
workCalendar: {
|
|
22954
|
+
rows: (value) => value ? [{
|
|
22955
|
+
...value,
|
|
22956
|
+
id: "calendar"
|
|
22957
|
+
}] : [],
|
|
22958
|
+
value: (rows) => {
|
|
22959
|
+
if (!rows[0]) return null;
|
|
22960
|
+
const { id: _id, ...calendar } = rows[0];
|
|
22961
|
+
return calendar;
|
|
22962
|
+
}
|
|
22291
22963
|
}
|
|
22292
22964
|
};
|
|
22293
22965
|
isKeyed = (key) => key in KEYED;
|
package/lib/cli.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
lifeboatsText,
|
|
40
40
|
followedText,
|
|
41
41
|
soundingsText,
|
|
42
|
+
ashoreText,
|
|
42
43
|
voyageText,
|
|
43
44
|
whyText,
|
|
44
45
|
handoverText,
|
|
@@ -213,12 +214,16 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
213
214
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
214
215
|
a note for later: sealed until
|
|
215
216
|
its day, milestone, or Off track
|
|
216
|
-
harbor sound TASK [
|
|
217
|
+
harbor sound TASK [--answer DAYS|--ask NAMES|--settle DAYS] [--why TEXT]
|
|
217
218
|
a sounding: ask a few people how
|
|
218
219
|
many days a task will take, give
|
|
219
220
|
your answer, or read the spread
|
|
220
221
|
harbor soundings [PROJECT] open tasks with a sounding, and
|
|
221
222
|
the ones waiting on your answer
|
|
223
|
+
harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
|
|
224
|
+
public holidays and time away
|
|
225
|
+
coming up, and whose holidays
|
|
226
|
+
the harbor (or someone) takes
|
|
222
227
|
harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
|
|
223
228
|
[--keep ID,ID] [--confirm]
|
|
224
229
|
hand someone's work to the crew
|
|
@@ -449,6 +454,8 @@ const FLAG = {
|
|
|
449
454
|
days: "the days",
|
|
450
455
|
why: "--why",
|
|
451
456
|
people: "--ask",
|
|
457
|
+
countries: "--country",
|
|
458
|
+
country: "--country",
|
|
452
459
|
};
|
|
453
460
|
|
|
454
461
|
/** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
|
|
@@ -731,7 +738,8 @@ const DETAIL = {
|
|
|
731
738
|
" harbor sound TASK --ask crew writes: asks you, its owner and whoever",
|
|
732
739
|
" has open work on its project",
|
|
733
740
|
' harbor sound TASK --ask "Sam,Priya" writes: asks those people',
|
|
734
|
-
' harbor sound TASK 4 [--why "TEXT"]
|
|
741
|
+
' harbor sound TASK --answer 4 [--why "TEXT"]',
|
|
742
|
+
" writes: your answer, in working days",
|
|
735
743
|
" harbor sound TASK --settle 6 writes: the days you agreed on after",
|
|
736
744
|
" talking a wide spread through",
|
|
737
745
|
"",
|
|
@@ -739,6 +747,19 @@ const DETAIL = {
|
|
|
739
747
|
" everyone has. Close guesses give the task an estimate; far-apart ones",
|
|
740
748
|
" say talk first. The forecast draws on every answer.",
|
|
741
749
|
],
|
|
750
|
+
ashore: [
|
|
751
|
+
" harbor ashore [--weeks N] reads: holidays and time away in the",
|
|
752
|
+
" next N weeks (8 unless you say)",
|
|
753
|
+
" harbor ashore --country US,GB writes: whose public holidays the",
|
|
754
|
+
" harbor takes (none for no holidays)",
|
|
755
|
+
" harbor ashore --person NAME --country GB",
|
|
756
|
+
" writes: one person's own country",
|
|
757
|
+
" (harbor to take the harbor's again)",
|
|
758
|
+
"",
|
|
759
|
+
" The forecast, the weeks ahead, the timeline and calendar feeds count",
|
|
760
|
+
" these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
|
|
761
|
+
" NZ US (national holidays only).",
|
|
762
|
+
],
|
|
742
763
|
soundings: [
|
|
743
764
|
" harbor soundings [PROJECT] reads: open tasks with a sounding",
|
|
744
765
|
" (all projects when left out), and the",
|
|
@@ -2024,7 +2045,8 @@ export async function run(argv, io = {}) {
|
|
|
2024
2045
|
);
|
|
2025
2046
|
if (!values.project) throw new HarborError("Say which project: --project NAME.");
|
|
2026
2047
|
// Several at once: one title a line on stdin, with no title (or -) after add.
|
|
2027
|
-
const piped =
|
|
2048
|
+
const piped =
|
|
2049
|
+
positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
|
|
2028
2050
|
const stdin = io.stdin ?? process.stdin;
|
|
2029
2051
|
const titles =
|
|
2030
2052
|
piped && (io.stdin || !process.stdin.isTTY)
|
|
@@ -2033,28 +2055,34 @@ export async function run(argv, io = {}) {
|
|
|
2033
2055
|
.map((l) => l.trim())
|
|
2034
2056
|
.filter(Boolean)
|
|
2035
2057
|
: [text(positionals, "the task")];
|
|
2036
|
-
if (titles.length === 0)
|
|
2037
|
-
|
|
2058
|
+
if (titles.length === 0)
|
|
2059
|
+
throw new HarborError("Say the task, or give one title a line on stdin.");
|
|
2060
|
+
if (titles.length > 100)
|
|
2061
|
+
throw new HarborError(
|
|
2062
|
+
"That's more than 100 tasks; harbor import FILE takes a spreadsheet.",
|
|
2063
|
+
);
|
|
2038
2064
|
const added = [];
|
|
2039
2065
|
for (const title of titles) {
|
|
2040
|
-
const r = await client
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2066
|
+
const r = await client
|
|
2067
|
+
.call("create_task", {
|
|
2068
|
+
harbor,
|
|
2069
|
+
project: values.project,
|
|
2070
|
+
title,
|
|
2071
|
+
// Yours unless you say otherwise, as with n in the app.
|
|
2072
|
+
assignee: values.assignee ?? "me",
|
|
2073
|
+
due: duePilot(values.due) ? undefined : dueDate(values.due),
|
|
2074
|
+
suggestedDue: duePilot(values.due),
|
|
2075
|
+
priority: values.priority,
|
|
2076
|
+
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
2077
|
+
overLimit: values["over-limit"] || undefined,
|
|
2078
|
+
})
|
|
2079
|
+
.catch((err) => {
|
|
2080
|
+
if (err instanceof HarborError) {
|
|
2081
|
+
const done = added.length ? ` (added ${added.length} before it)` : "";
|
|
2082
|
+
throw new HarborError(`${hint(err.message, values.project)}${done}`);
|
|
2083
|
+
}
|
|
2084
|
+
throw err;
|
|
2085
|
+
});
|
|
2058
2086
|
if (values.comment)
|
|
2059
2087
|
r.comment = (
|
|
2060
2088
|
await client.call("add_comment", {
|
|
@@ -2209,19 +2237,26 @@ export async function run(argv, io = {}) {
|
|
|
2209
2237
|
const number = (v, name) => {
|
|
2210
2238
|
if (v === undefined) return undefined;
|
|
2211
2239
|
const n = Number(v);
|
|
2212
|
-
if (!Number.isFinite(n) || n <= 0)
|
|
2240
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
2241
|
+
throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
|
|
2213
2242
|
return n;
|
|
2214
2243
|
};
|
|
2215
2244
|
const [task, day, ...extra] = positionals;
|
|
2216
2245
|
if (extra.length) throw new HarborError("Give a task key and a day: harbor week RR-42 wed.");
|
|
2217
2246
|
const dayHours = number(values["day-hours"], "day-hours");
|
|
2218
|
-
const show = (r) =>
|
|
2247
|
+
const show = (r) =>
|
|
2248
|
+
[r.done, weekText(r, { ...opts, review: Boolean(values.review) })]
|
|
2249
|
+
.filter(Boolean)
|
|
2250
|
+
.join("\n\n");
|
|
2219
2251
|
if (!task && dayHours === undefined) {
|
|
2220
|
-
if (values.off || values.hours)
|
|
2252
|
+
if (values.off || values.hours)
|
|
2253
|
+
throw new HarborError("Say which task: harbor week RR-42 wed --hours 3.");
|
|
2221
2254
|
return call("get_week", { harbor, week }, show);
|
|
2222
2255
|
}
|
|
2223
2256
|
if (task && !day && !values.off)
|
|
2224
|
-
throw new HarborError(
|
|
2257
|
+
throw new HarborError(
|
|
2258
|
+
`Say which day for ${task}: harbor week ${task} wed, or --off to take it off the week.`,
|
|
2259
|
+
);
|
|
2225
2260
|
return call(
|
|
2226
2261
|
"plan_week",
|
|
2227
2262
|
{
|
|
@@ -2485,9 +2520,14 @@ export async function run(argv, io = {}) {
|
|
|
2485
2520
|
);
|
|
2486
2521
|
}
|
|
2487
2522
|
case "sound": {
|
|
2488
|
-
const { positionals, values, harbor } = parse(spec([], ["why", "ask", "settle"]));
|
|
2489
|
-
const [task,
|
|
2523
|
+
const { positionals, values, harbor } = parse(spec([], ["answer", "why", "ask", "settle"]));
|
|
2524
|
+
const [task, ...extra] = positionals;
|
|
2490
2525
|
if (!task) throw new HarborError("Give a task's key, like harbor sound RR-4.");
|
|
2526
|
+
// A bare number used to be the answer; it writes, so it now needs its flag.
|
|
2527
|
+
if (extra.length === 1 && /^\d+(\.\d+)?(d|days?)?$/i.test(extra[0]))
|
|
2528
|
+
throw new HarborError(
|
|
2529
|
+
`To give your answer, say so: harbor sound ${task} --answer ${extra[0]}.`,
|
|
2530
|
+
);
|
|
2491
2531
|
if (extra.length) throw new HarborError(`Unexpected "${extra.join(" ")}".`);
|
|
2492
2532
|
const number = (text) => {
|
|
2493
2533
|
const n = Number(String(text).replace(/d(ays?)?$/i, ""));
|
|
@@ -2495,11 +2535,14 @@ export async function run(argv, io = {}) {
|
|
|
2495
2535
|
throw new HarborError(`Give days as a number, like 3 or 2.5 (not "${text}").`);
|
|
2496
2536
|
return n;
|
|
2497
2537
|
};
|
|
2538
|
+
const days = values.answer;
|
|
2498
2539
|
const given = [days !== undefined, values.ask !== undefined, values.settle !== undefined];
|
|
2499
2540
|
if (given.filter(Boolean).length > 1)
|
|
2500
2541
|
throw new HarborError("Answer, ask or settle: one at a time.");
|
|
2501
2542
|
if (values.why !== undefined && days === undefined)
|
|
2502
|
-
throw new HarborError(
|
|
2543
|
+
throw new HarborError(
|
|
2544
|
+
'--why goes with your answer: harbor sound TASK --answer 4 --why "TEXT".',
|
|
2545
|
+
);
|
|
2503
2546
|
if (values.ask !== undefined) {
|
|
2504
2547
|
const crew = /^crew$/i.test(values.ask.trim())
|
|
2505
2548
|
? undefined
|
|
@@ -2507,11 +2550,10 @@ export async function run(argv, io = {}) {
|
|
|
2507
2550
|
.split(",")
|
|
2508
2551
|
.map((x) => x.trim())
|
|
2509
2552
|
.filter(Boolean);
|
|
2510
|
-
return call(
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
`Asked ${r.asked.people.join(", ")} how many days ${r.asked.key ?? r.asked.title} will take. The answers show together once everyone's in.`,
|
|
2553
|
+
return call("take_sounding", { harbor, task, ...(crew ? { people: crew } : {}) }, (r) =>
|
|
2554
|
+
!crew && r.asked.people.length === 1
|
|
2555
|
+
? `Only you are on this crew, so the sounding on ${r.asked.key ?? r.asked.title} is just your guess. Ask others by name: harbor sound ${task} --ask "Sam,Priya".`
|
|
2556
|
+
: `Asked ${r.asked.people.join(", ")} how many days ${r.asked.key ?? r.asked.title} will take. The answers show together once everyone's in.`,
|
|
2515
2557
|
);
|
|
2516
2558
|
}
|
|
2517
2559
|
if (values.settle !== undefined) {
|
|
@@ -2543,6 +2585,39 @@ export async function run(argv, io = {}) {
|
|
|
2543
2585
|
soundingsText(r),
|
|
2544
2586
|
);
|
|
2545
2587
|
}
|
|
2588
|
+
case "ashore": {
|
|
2589
|
+
const { positionals, values, harbor } = parse(spec([], ["weeks", "country", "person"]));
|
|
2590
|
+
none(positionals);
|
|
2591
|
+
if (values.person && values.country === undefined)
|
|
2592
|
+
throw new HarborError("Give their country too: --person NAME --country GB (or harbor).");
|
|
2593
|
+
if (values.country !== undefined) {
|
|
2594
|
+
if (values.person) {
|
|
2595
|
+
return call(
|
|
2596
|
+
"set_holidays",
|
|
2597
|
+
{ harbor, person: values.person, country: values.country },
|
|
2598
|
+
(r) =>
|
|
2599
|
+
r.country === "harbor"
|
|
2600
|
+
? `${r.person} takes the harbor's holidays.`
|
|
2601
|
+
: `${r.person} takes ${r.country} holidays.`,
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
const countries = /^none$/i.test(values.country.trim())
|
|
2605
|
+
? []
|
|
2606
|
+
: values.country
|
|
2607
|
+
.split(",")
|
|
2608
|
+
.map((c) => c.trim())
|
|
2609
|
+
.filter(Boolean);
|
|
2610
|
+
return call("set_holidays", { harbor, countries }, (r) =>
|
|
2611
|
+
r.harbor.length
|
|
2612
|
+
? `The harbor takes public holidays from ${r.harbor.map((c) => c.name).join(", ")}.`
|
|
2613
|
+
: "The harbor takes no public holidays now.",
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
const weeks = values.weeks === undefined ? 8 : Number(values.weeks);
|
|
2617
|
+
if (!Number.isInteger(weeks) || weeks < 1 || weeks > 52)
|
|
2618
|
+
throw new HarborError("--weeks is a whole number from 1 to 52.");
|
|
2619
|
+
return call("time_ashore", { harbor, days: weeks * 7 }, (r) => ashoreText(r, weeks));
|
|
2620
|
+
}
|
|
2546
2621
|
case "why": {
|
|
2547
2622
|
const { positionals, harbor } = parse(spec());
|
|
2548
2623
|
const of = positionals.join(" ").trim();
|
package/lib/format.mjs
CHANGED
|
@@ -752,7 +752,7 @@ export function soundingText(task, s, { answered = false } = {}) {
|
|
|
752
752
|
for (const a of s.answers)
|
|
753
753
|
lines.push(` You said ${daysText(a.days)}${a.why ? ` · "${a.why}"` : ""}`);
|
|
754
754
|
if (s.youWereAsked && !s.answers.length)
|
|
755
|
-
lines.push(` Give yours: harbor sound ${task.key ?? task.id} DAYS`);
|
|
755
|
+
lines.push(` Give yours: harbor sound ${task.key ?? task.id} --answer DAYS`);
|
|
756
756
|
return lines.join("\n");
|
|
757
757
|
}
|
|
758
758
|
lines.push(` ${spreadLine(s.answers)}`);
|
|
@@ -768,6 +768,26 @@ export function soundingText(task, s, { answered = false } = {}) {
|
|
|
768
768
|
return lines.join("\n");
|
|
769
769
|
}
|
|
770
770
|
|
|
771
|
+
/** time_ashore: whose holidays count, then the days off coming up, soonest first. */
|
|
772
|
+
export function ashoreText(r, weeks = 8) {
|
|
773
|
+
const lines = [];
|
|
774
|
+
const countries = r.countries ?? [];
|
|
775
|
+
lines.push(
|
|
776
|
+
countries.length
|
|
777
|
+
? `Time ashore · holidays from ${countries.map((c) => c.name).join(", ")}`
|
|
778
|
+
: "Time ashore · no holiday country set (harbor ashore --country US)",
|
|
779
|
+
);
|
|
780
|
+
for (const p of r.people ?? []) lines.push(` ${p.name} takes ${p.country} holidays`);
|
|
781
|
+
const rows = [
|
|
782
|
+
...(r.holidays ?? []).map((h) => [h.date, h.name]),
|
|
783
|
+
...(r.away ?? []).map((a) => [a.from, `${a.name} away, back ${a.back}`]),
|
|
784
|
+
].sort((a, b) => a[0].localeCompare(b[0]));
|
|
785
|
+
lines.push("");
|
|
786
|
+
if (!rows.length) lines.push(` Nothing in the next ${weeks} weeks.`);
|
|
787
|
+
for (const [day, what] of rows) lines.push(` ${day} ${what}`);
|
|
788
|
+
return lines.join("\n");
|
|
789
|
+
}
|
|
790
|
+
|
|
771
791
|
/** list_soundings: the ones waiting on you first, then the rest, wide ones flagged. */
|
|
772
792
|
export function soundingsText(r) {
|
|
773
793
|
const all = r.soundings ?? [];
|