@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 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 [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
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
- /** Calendar days from `from` until `days` working days (Monday to Friday) have passed. */
3711
- function workingDaysAhead(days, from) {
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 wd = addDays(from, d).getDay();
3717
- if (wd !== 0 && wd !== 6) left -= 1;
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
- const closed = pace === 1 ? drawn : Math.floor(drawn * pace) + (next() < drawn * pace % 1 ? 1 : 0);
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
- let v = memo.get(d);
3863
- if (v === void 0) memo.set(d, v = workingDaysAhead(d, base));
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 paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1));
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`), { reflections: data.reflections });
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: iso$2(addDays(origin, i * 7)),
8852
- due: 0
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, reflections) {
18229
- const tide = tideForecast(project, tasks, now, { reflections });
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.reflections),
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.reflections),
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 `priorityRules` may be missing from older documents. */
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 [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
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"] writes: your answer, in working days',
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 = positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
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) throw new HarborError("Say the task, or give one title a line on stdin.");
2037
- if (titles.length > 100) throw new HarborError("That's more than 100 tasks; harbor import FILE takes a spreadsheet.");
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.call("create_task", {
2041
- harbor,
2042
- project: values.project,
2043
- title,
2044
- // Yours unless you say otherwise, as with n in the app.
2045
- assignee: values.assignee ?? "me",
2046
- due: duePilot(values.due) ? undefined : dueDate(values.due),
2047
- suggestedDue: duePilot(values.due),
2048
- priority: values.priority,
2049
- status: values.status === undefined ? undefined : statusWord(values.status),
2050
- overLimit: values["over-limit"] || undefined,
2051
- }).catch((err) => {
2052
- if (err instanceof HarborError) {
2053
- const done = added.length ? ` (added ${added.length} before it)` : "";
2054
- throw new HarborError(`${hint(err.message, values.project)}${done}`);
2055
- }
2056
- throw err;
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) throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
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) => [r.done, weekText(r, { ...opts, review: Boolean(values.review) })].filter(Boolean).join("\n\n");
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) throw new HarborError("Say which task: harbor week RR-42 wed --hours 3.");
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(`Say which day for ${task}: harbor week ${task} wed, or --off to take it off the week.`);
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, days, ...extra] = positionals;
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('--why goes with your answer: harbor sound TASK 4 --why "TEXT".');
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
- "take_sounding",
2512
- { harbor, task, ...(crew ? { people: crew } : {}) },
2513
- (r) =>
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 ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",