@harborppm/cli 0.95.0 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.mjs CHANGED
@@ -2688,13 +2688,13 @@ var init_holidays = __esmMin((() => {
2688
2688
  // src/lib/tide.ts
2689
2689
  /** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
2690
2690
  function weeklyFinished(tasks, startDate, now, weeks = 6) {
2691
- const today = noon$3(todayISO(now));
2692
- const age = Math.floor(differenceInCalendarDays(today, noon$3(startDate)) / 7) + 1;
2691
+ const today = noon$4(todayISO(now));
2692
+ const age = Math.floor(differenceInCalendarDays(today, noon$4(startDate)) / 7) + 1;
2693
2693
  const span = Math.max(1, Math.min(weeks, age));
2694
2694
  const counts = new Array(span).fill(0);
2695
2695
  for (const t of tasks) {
2696
2696
  if (t.status !== "done") continue;
2697
- const at = t.completedAt ? noon$3(t.completedAt) : today;
2697
+ const at = t.completedAt ? noon$4(t.completedAt) : today;
2698
2698
  const back = Math.max(0, differenceInCalendarDays(today, at));
2699
2699
  const week = Math.floor(back / 7);
2700
2700
  if (week < span) counts[span - 1 - week] += 1;
@@ -2872,7 +2872,7 @@ function tideOptions(data) {
2872
2872
  function tideForecast(project, tasks, now, opts = {}) {
2873
2873
  if (tasks.length === 0) return { kind: "empty" };
2874
2874
  const remaining = tasks.filter((t) => t.status !== "done").length;
2875
- if (remaining === 0) return { kind: "done" };
2875
+ if (remaining === 0 || project.health === "done") return { kind: "done" };
2876
2876
  if (project.health === "paused") return {
2877
2877
  kind: "paused",
2878
2878
  remaining
@@ -2885,7 +2885,7 @@ function tideForecast(project, tasks, now, opts = {}) {
2885
2885
  finished
2886
2886
  };
2887
2887
  const today = todayISO(now);
2888
- const base = noon$3(today);
2888
+ const base = noon$4(today);
2889
2889
  const { shares, crew } = weekShares(tasks, base, opts.calendar);
2890
2890
  const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1), shares);
2891
2891
  const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
@@ -2894,12 +2894,12 @@ function tideForecast(project, tasks, now, opts = {}) {
2894
2894
  if (t.status === "done" || !t.dueDate) continue;
2895
2895
  if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
2896
2896
  }
2897
- const floor = lastDue ? differenceInCalendarDays(noon$3(lastDue.dueDate), base) : 0;
2897
+ const floor = lastDue ? differenceInCalendarDays(noon$4(lastDue.dueDate), base) : 0;
2898
2898
  const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`), opts.calendar);
2899
2899
  const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
2900
2900
  const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
2901
2901
  const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
2902
- const toEnd = differenceInCalendarDays(noon$3(project.endDate), base);
2902
+ const toEnd = differenceInCalendarDays(noon$4(project.endDate), base);
2903
2903
  const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
2904
2904
  const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
2905
2905
  const curve = [];
@@ -2952,7 +2952,7 @@ function tideQuiet(tide) {
2952
2952
  default: return null;
2953
2953
  }
2954
2954
  }
2955
- var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, TIDE_MIN_FINISHED, noon$3;
2955
+ var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, TIDE_MIN_FINISHED, noon$4;
2956
2956
  var init_tide = __esmMin((() => {
2957
2957
  init_date_fns();
2958
2958
  init_format();
@@ -2963,7 +2963,7 @@ var init_tide = __esmMin((() => {
2963
2963
  MAX_WEEKS = 104;
2964
2964
  ROUGH_BELOW = 5;
2965
2965
  TIDE_MIN_FINISHED = 3;
2966
- noon$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
2966
+ noon$4 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
2967
2967
  }));
2968
2968
 
2969
2969
  // src/lib/barnacles.ts
@@ -2980,7 +2980,7 @@ function planSetAt(project, tasks) {
2980
2980
  for (const t of tasks) if (!first || t.createdAt < first) first = t.createdAt;
2981
2981
  first = (first ?? project.startDate).slice(0, 10);
2982
2982
  return {
2983
- since: `${isoDay$3(addDays(noon$2(first), 7))}T00:00:00`,
2983
+ since: `${isoDay$3(addDays(noon$3(first), 7))}T00:00:00`,
2984
2984
  from: "start"
2985
2985
  };
2986
2986
  }
@@ -2992,20 +2992,20 @@ function planSetAt(project, tasks) {
2992
2992
  function barnaclesOf(project, tasks, people, now, slip = null) {
2993
2993
  const { since, from } = planSetAt(project, tasks);
2994
2994
  const today = todayISO(now);
2995
- const weekAgo = isoDay$3(addDays(noon$2(today), -7));
2995
+ const weekAgo = isoDay$3(addDays(noon$3(today), -7));
2996
2996
  let planned = 0;
2997
2997
  const added = [];
2998
2998
  for (const t of tasks) if (t.createdAt > since) added.push(t);
2999
2999
  else planned += 1;
3000
- const first = startOfWeek(noon$2(since), { weekStartsOn: 1 });
3001
- const span = Math.max(0, Math.floor(differenceInCalendarDays(noon$2(today), first) / 7));
3000
+ const first = startOfWeek(noon$3(since), { weekStartsOn: 1 });
3001
+ const span = Math.max(0, Math.floor(differenceInCalendarDays(noon$3(today), first) / 7));
3002
3002
  const weeks = Array.from({ length: Math.min(span + 1, 104) }, (_, i) => ({
3003
3003
  week: isoDay$3(addDays(first, i * 7)),
3004
3004
  added: 0
3005
3005
  }));
3006
3006
  const counts = /* @__PURE__ */ new Map();
3007
3007
  for (const t of added) {
3008
- const i = Math.floor(differenceInCalendarDays(noon$2(t.createdAt), first) / 7);
3008
+ const i = Math.floor(differenceInCalendarDays(noon$3(t.createdAt), first) / 7);
3009
3009
  const slot = weeks[Math.min(Math.max(0, i), weeks.length - 1)];
3010
3010
  if (slot) slot.added += 1;
3011
3011
  const who = t.createdById ?? null;
@@ -3023,7 +3023,7 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
3023
3023
  const byDay = /* @__PURE__ */ new Map();
3024
3024
  for (const t of added) byDay.set(t.createdAt.slice(0, 10), (byDay.get(t.createdAt.slice(0, 10)) ?? 0) + 1);
3025
3025
  const [bulkDay, bulk] = [...byDay].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
3026
- const age = differenceInCalendarDays(noon$2(today), addDays(noon$2(since), -7)) / 7;
3026
+ const age = differenceInCalendarDays(noon$3(today), addDays(noon$3(since), -7)) / 7;
3027
3027
  let caveat = null;
3028
3028
  if (added.length && bulkDay && bulk >= 5 && bulk >= added.length * .5) caveat = `${bulk} of the ${added.length} came in on ${shortDate(bulkDay)}, which looks like the plan being loaded rather than growth, so treat this as rough. Setting a baseline counts from then on.`;
3029
3029
  else if (added.length && from === "start" && (planned < 5 || age < 3)) caveat = "There's no baseline yet and the plan is young, so its first weeks may be the plan being written: treat this as rough. Setting a baseline counts from then on.";
@@ -3050,8 +3050,8 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
3050
3050
  */
3051
3051
  function slipAgainstPlan(project, tasks, now, tide = tideForecast(project, tasks, now)) {
3052
3052
  const plannedEnd = (project.baselineEnd ?? project.endDate).slice(0, 10);
3053
- const likely = tide.kind === "forecast" && tide.likely ? differenceInCalendarDays(noon$2(tide.likely), noon$2(plannedEnd)) : 0;
3054
- const moved = project.baselineEnd ? differenceInCalendarDays(noon$2(project.endDate), noon$2(project.baselineEnd)) : 0;
3053
+ const likely = tide.kind === "forecast" && tide.likely ? differenceInCalendarDays(noon$3(tide.likely), noon$3(plannedEnd)) : 0;
3054
+ const moved = project.baselineEnd ? differenceInCalendarDays(noon$3(project.endDate), noon$3(project.baselineEnd)) : 0;
3055
3055
  const slip = Math.max(likely, moved);
3056
3056
  return slip > 0 ? slip : null;
3057
3057
  }
@@ -3084,7 +3084,7 @@ function barnaclesShort(b) {
3084
3084
  function briefWorthy(b) {
3085
3085
  return b.from === "baseline" && !b.caveat && b.added >= 3 && (b.share == null || b.share >= .15);
3086
3086
  }
3087
- var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$7;
3087
+ var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$3, isoDay$3, plural$7;
3088
3088
  var init_barnacles = __esmMin((() => {
3089
3089
  init_date_fns();
3090
3090
  init_format();
@@ -3096,7 +3096,7 @@ var init_barnacles = __esmMin((() => {
3096
3096
  BULK_MIN = 5;
3097
3097
  BRIEF_MIN_ADDED = 3;
3098
3098
  BRIEF_MIN_SHARE = .15;
3099
- noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3099
+ noon$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3100
3100
  isoDay$3 = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
3101
3101
  plural$7 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
3102
3102
  }));
@@ -5942,18 +5942,18 @@ init_tide();
5942
5942
  const BURN_DAYS = 56;
5943
5943
  /** A window shorter than a week says too little to read a rate from. */
5944
5944
  const MIN_DAYS = 7;
5945
- const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5945
+ const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5946
5946
  /**
5947
5947
  * Spend a day over the last BURN_DAYS (or the project's life, when it's
5948
5948
  * younger), from the ledger; null when nothing was spent in that time.
5949
5949
  */
5950
5950
  function burnPerDay(entries, startDate, now) {
5951
- const today = noon$1(todayISO(now));
5952
- const age = differenceInCalendarDays(today, noon$1(startDate)) + 1;
5951
+ const today = noon$2(todayISO(now));
5952
+ const age = differenceInCalendarDays(today, noon$2(startDate)) + 1;
5953
5953
  const span = Math.max(MIN_DAYS, Math.min(56, age));
5954
5954
  let sum = 0;
5955
5955
  for (const e of entries) {
5956
- const back = differenceInCalendarDays(today, noon$1(e.date));
5956
+ const back = differenceInCalendarDays(today, noon$2(e.date));
5957
5957
  if (back >= 0 && back < span) sum += e.amount;
5958
5958
  }
5959
5959
  return sum > 0 ? sum / span : null;
@@ -5975,8 +5975,8 @@ function slipCost(project, tide, entries, now) {
5975
5975
  const from = project.endDate > today ? project.endDate : today;
5976
5976
  const point = (date) => {
5977
5977
  if (!date) return null;
5978
- const daysLate = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(from)));
5979
- const ahead = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(today)));
5978
+ const daysLate = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(from)));
5979
+ const ahead = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(today)));
5980
5980
  return {
5981
5981
  date,
5982
5982
  daysLate,
@@ -6818,6 +6818,534 @@ function applyTheme(name) {
6818
6818
  }
6819
6819
  applyTheme("dark");
6820
6820
 
6821
+ // src/lib/signal-flags.ts
6822
+ const FLAG_W = 30;
6823
+ const FLAG_H = 20;
6824
+ const W = 30;
6825
+ const H = 20;
6826
+ const full = (c) => ({
6827
+ r: [
6828
+ 0,
6829
+ 0,
6830
+ W,
6831
+ H
6832
+ ],
6833
+ c
6834
+ });
6835
+ const rows = (...cs) => cs.map((c, i) => ({
6836
+ r: [
6837
+ 0,
6838
+ H / cs.length * i,
6839
+ W,
6840
+ H / cs.length
6841
+ ],
6842
+ c
6843
+ }));
6844
+ const cols = (...cs) => cs.map((c, i) => ({
6845
+ r: [
6846
+ W / cs.length * i,
6847
+ 0,
6848
+ W / cs.length,
6849
+ H
6850
+ ],
6851
+ c
6852
+ }));
6853
+ const quarters = (a, b, c, d) => [
6854
+ {
6855
+ r: [
6856
+ 0,
6857
+ 0,
6858
+ W / 2,
6859
+ H / 2
6860
+ ],
6861
+ c: a
6862
+ },
6863
+ {
6864
+ r: [
6865
+ W / 2,
6866
+ 0,
6867
+ W / 2,
6868
+ H / 2
6869
+ ],
6870
+ c: b
6871
+ },
6872
+ {
6873
+ r: [
6874
+ 0,
6875
+ H / 2,
6876
+ W / 2,
6877
+ H / 2
6878
+ ],
6879
+ c
6880
+ },
6881
+ {
6882
+ r: [
6883
+ W / 2,
6884
+ H / 2,
6885
+ W / 2,
6886
+ H / 2
6887
+ ],
6888
+ c: d
6889
+ }
6890
+ ];
6891
+ const square = (field, mid, inset = 6) => [full(field), {
6892
+ r: [
6893
+ inset * 1.5,
6894
+ inset,
6895
+ W - inset * 3,
6896
+ H - inset * 2
6897
+ ],
6898
+ c: mid
6899
+ }];
6900
+ const cross = (field, bar, t = 4) => [
6901
+ full(field),
6902
+ {
6903
+ r: [
6904
+ (W - t) / 2,
6905
+ 0,
6906
+ t,
6907
+ H
6908
+ ],
6909
+ c: bar
6910
+ },
6911
+ {
6912
+ r: [
6913
+ 0,
6914
+ (H - t) / 2,
6915
+ W,
6916
+ t
6917
+ ],
6918
+ c: bar
6919
+ }
6920
+ ];
6921
+ const saltire = (field, bar) => [
6922
+ full(field),
6923
+ {
6924
+ p: [
6925
+ 0,
6926
+ 0,
6927
+ 3.5,
6928
+ 0,
6929
+ W,
6930
+ 17.5,
6931
+ W,
6932
+ H,
6933
+ 26.5,
6934
+ H,
6935
+ 0,
6936
+ 2.5
6937
+ ],
6938
+ c: bar
6939
+ },
6940
+ {
6941
+ p: [
6942
+ W,
6943
+ 0,
6944
+ W,
6945
+ 2.5,
6946
+ 3.5,
6947
+ H,
6948
+ 0,
6949
+ H,
6950
+ 0,
6951
+ 17.5,
6952
+ 26.5,
6953
+ 0
6954
+ ],
6955
+ c: bar
6956
+ }
6957
+ ];
6958
+ const disc = (field, dot, r = 5.5) => [full(field), {
6959
+ o: [
6960
+ W / 2,
6961
+ H / 2,
6962
+ r
6963
+ ],
6964
+ c: dot
6965
+ }];
6966
+ /** The 26 letter flags. */
6967
+ const LETTERS = {
6968
+ A: [
6969
+ "Alfa",
6970
+ cols("w", "b"),
6971
+ ["w", "b"]
6972
+ ],
6973
+ B: [
6974
+ "Bravo",
6975
+ [full("r")],
6976
+ ["r", "r"]
6977
+ ],
6978
+ C: [
6979
+ "Charlie",
6980
+ rows("b", "w", "r", "w", "b"),
6981
+ ["b", "r"]
6982
+ ],
6983
+ D: [
6984
+ "Delta",
6985
+ [full("y"), {
6986
+ r: [
6987
+ 0,
6988
+ H / 4,
6989
+ W,
6990
+ H / 2
6991
+ ],
6992
+ c: "b"
6993
+ }],
6994
+ ["y", "b"]
6995
+ ],
6996
+ E: [
6997
+ "Echo",
6998
+ rows("b", "r"),
6999
+ ["b", "r"]
7000
+ ],
7001
+ F: [
7002
+ "Foxtrot",
7003
+ [full("w"), {
7004
+ p: [
7005
+ W / 2,
7006
+ 1,
7007
+ 29,
7008
+ H / 2,
7009
+ W / 2,
7010
+ 19,
7011
+ 1,
7012
+ H / 2
7013
+ ],
7014
+ c: "r"
7015
+ }],
7016
+ ["w", "r"]
7017
+ ],
7018
+ G: [
7019
+ "Golf",
7020
+ cols("y", "b", "y", "b", "y", "b"),
7021
+ ["y", "b"]
7022
+ ],
7023
+ H: [
7024
+ "Hotel",
7025
+ cols("w", "r"),
7026
+ ["w", "r"]
7027
+ ],
7028
+ I: [
7029
+ "India",
7030
+ disc("y", "k"),
7031
+ ["y", "k"]
7032
+ ],
7033
+ J: [
7034
+ "Juliett",
7035
+ rows("b", "w", "b"),
7036
+ ["b", "w"]
7037
+ ],
7038
+ K: [
7039
+ "Kilo",
7040
+ cols("y", "b"),
7041
+ ["y", "b"]
7042
+ ],
7043
+ L: [
7044
+ "Lima",
7045
+ quarters("y", "k", "k", "y"),
7046
+ ["y", "k"]
7047
+ ],
7048
+ M: [
7049
+ "Mike",
7050
+ saltire("b", "w"),
7051
+ ["b", "w"]
7052
+ ],
7053
+ N: [
7054
+ "November",
7055
+ [full("w"), ...[
7056
+ 0,
7057
+ 1,
7058
+ 2,
7059
+ 3
7060
+ ].flatMap((row) => [
7061
+ 0,
7062
+ 1,
7063
+ 2,
7064
+ 3
7065
+ ].filter((col) => (row + col) % 2 === 0).map((col) => ({
7066
+ r: [
7067
+ col * (W / 4),
7068
+ row * (H / 4),
7069
+ W / 4,
7070
+ H / 4
7071
+ ],
7072
+ c: "b"
7073
+ })))],
7074
+ ["b", "w"]
7075
+ ],
7076
+ O: [
7077
+ "Oscar",
7078
+ [full("y"), {
7079
+ p: [
7080
+ 0,
7081
+ 0,
7082
+ W,
7083
+ 0,
7084
+ W,
7085
+ H
7086
+ ],
7087
+ c: "r"
7088
+ }],
7089
+ ["y", "r"]
7090
+ ],
7091
+ P: [
7092
+ "Papa",
7093
+ square("b", "w"),
7094
+ ["b", "w"]
7095
+ ],
7096
+ Q: [
7097
+ "Quebec",
7098
+ [full("y")],
7099
+ ["y", "y"]
7100
+ ],
7101
+ R: [
7102
+ "Romeo",
7103
+ cross("r", "y"),
7104
+ ["r", "y"]
7105
+ ],
7106
+ S: [
7107
+ "Sierra",
7108
+ square("w", "b"),
7109
+ ["w", "b"]
7110
+ ],
7111
+ T: [
7112
+ "Tango",
7113
+ cols("r", "w", "b"),
7114
+ ["r", "b"]
7115
+ ],
7116
+ U: [
7117
+ "Uniform",
7118
+ quarters("r", "w", "w", "r"),
7119
+ ["r", "w"]
7120
+ ],
7121
+ V: [
7122
+ "Victor",
7123
+ saltire("w", "r"),
7124
+ ["w", "r"]
7125
+ ],
7126
+ W: [
7127
+ "Whiskey",
7128
+ [
7129
+ full("b"),
7130
+ {
7131
+ r: [
7132
+ 4,
7133
+ 3,
7134
+ 22,
7135
+ 14
7136
+ ],
7137
+ c: "w"
7138
+ },
7139
+ {
7140
+ r: [
7141
+ 10,
7142
+ 7,
7143
+ 10,
7144
+ 6
7145
+ ],
7146
+ c: "r"
7147
+ }
7148
+ ],
7149
+ ["b", "r"]
7150
+ ],
7151
+ X: [
7152
+ "X-ray",
7153
+ cross("w", "b"),
7154
+ ["w", "b"]
7155
+ ],
7156
+ Y: [
7157
+ "Yankee",
7158
+ [full("y"), ...[
7159
+ -20,
7160
+ -10,
7161
+ 0,
7162
+ 10,
7163
+ 20,
7164
+ 30
7165
+ ].map((x) => ({
7166
+ p: [
7167
+ x,
7168
+ H,
7169
+ x + 5,
7170
+ H,
7171
+ x + 25,
7172
+ 0,
7173
+ x + 20,
7174
+ 0
7175
+ ],
7176
+ c: "r"
7177
+ }))],
7178
+ ["y", "r"]
7179
+ ],
7180
+ Z: [
7181
+ "Zulu",
7182
+ [
7183
+ {
7184
+ p: [
7185
+ 0,
7186
+ 0,
7187
+ W,
7188
+ 0,
7189
+ W / 2,
7190
+ H / 2
7191
+ ],
7192
+ c: "y"
7193
+ },
7194
+ {
7195
+ p: [
7196
+ W,
7197
+ 0,
7198
+ W,
7199
+ H,
7200
+ W / 2,
7201
+ H / 2
7202
+ ],
7203
+ c: "b"
7204
+ },
7205
+ {
7206
+ p: [
7207
+ 0,
7208
+ H,
7209
+ W,
7210
+ H,
7211
+ W / 2,
7212
+ H / 2
7213
+ ],
7214
+ c: "r"
7215
+ },
7216
+ {
7217
+ p: [
7218
+ 0,
7219
+ 0,
7220
+ W / 2,
7221
+ H / 2,
7222
+ 0,
7223
+ H
7224
+ ],
7225
+ c: "k"
7226
+ }
7227
+ ],
7228
+ ["k", "b"]
7229
+ ]
7230
+ };
7231
+ /** The ten numeral pennants. */
7232
+ const DIGITS = {
7233
+ "0": [
7234
+ "Zero",
7235
+ cols("y", "r", "y"),
7236
+ ["y", "r"]
7237
+ ],
7238
+ "1": [
7239
+ "One",
7240
+ disc("w", "r", 4),
7241
+ ["w", "r"]
7242
+ ],
7243
+ "2": [
7244
+ "Two",
7245
+ disc("b", "w", 4),
7246
+ ["b", "w"]
7247
+ ],
7248
+ "3": [
7249
+ "Three",
7250
+ cols("r", "w", "b"),
7251
+ ["r", "b"]
7252
+ ],
7253
+ "4": [
7254
+ "Four",
7255
+ cross("r", "w", 3.5),
7256
+ ["r", "w"]
7257
+ ],
7258
+ "5": [
7259
+ "Five",
7260
+ cols("y", "b"),
7261
+ ["y", "b"]
7262
+ ],
7263
+ "6": [
7264
+ "Six",
7265
+ rows("k", "w"),
7266
+ ["k", "w"]
7267
+ ],
7268
+ "7": [
7269
+ "Seven",
7270
+ rows("y", "r"),
7271
+ ["y", "r"]
7272
+ ],
7273
+ "8": [
7274
+ "Eight",
7275
+ cross("w", "r", 3.5),
7276
+ ["w", "r"]
7277
+ ],
7278
+ "9": [
7279
+ "Nine",
7280
+ quarters("w", "k", "r", "y"),
7281
+ ["w", "y"]
7282
+ ]
7283
+ };
7284
+ /** One flag, or null for a character the code has no flag for. */
7285
+ function signalFlag(char) {
7286
+ const c = char.toUpperCase();
7287
+ const letter = LETTERS[c];
7288
+ if (letter) {
7289
+ const [name, shapes, halves] = letter;
7290
+ return {
7291
+ char: c,
7292
+ name,
7293
+ shapes,
7294
+ halves,
7295
+ outline: c === "A" || c === "B" ? "swallowtail" : "flag"
7296
+ };
7297
+ }
7298
+ const digit = DIGITS[c];
7299
+ if (digit) {
7300
+ const [name, shapes, halves] = digit;
7301
+ return {
7302
+ char: c,
7303
+ name,
7304
+ shapes,
7305
+ halves,
7306
+ outline: "pennant"
7307
+ };
7308
+ }
7309
+ return null;
7310
+ }
7311
+ /** The most flags a hoist shows; longer keys keep their first four characters. */
7312
+ const MAX_FLAGS = 4;
7313
+ /** A key's flags, in order: letters and digits only, at most four. */
7314
+ function flagsFor(key) {
7315
+ if (!key) return [];
7316
+ const out = [];
7317
+ for (const ch of key) {
7318
+ const flag = signalFlag(ch);
7319
+ if (flag) out.push(flag);
7320
+ if (out.length === 4) break;
7321
+ }
7322
+ return out;
7323
+ }
7324
+ /** The flag an off-track project flies under its key. */
7325
+ const ASSISTANCE_FLAG = "V";
7326
+ /** "Romeo Romeo", read the way a signaller would say the hoist. */
7327
+ function spokenHoist(flags) {
7328
+ return flags.map((f) => f.name).join(" ");
7329
+ }
7330
+ /**
7331
+ * What the hoist says on hover: the key, how it's spoken, and, when the
7332
+ * project is off track, Victor's meaning.
7333
+ */
7334
+ function hoistLabel(key, flags, offTrack) {
7335
+ const base = `${key} in signal flags: ${spokenHoist(flags)}`;
7336
+ return offTrack ? `${base}, flying Victor, “I require assistance”` : base;
7337
+ }
7338
+ /** The flags a project flies: its key, plus Victor while it's off track. */
7339
+ function projectHoist(key, offTrack) {
7340
+ const flags = flagsFor(key);
7341
+ if (!flags.length || !key) return null;
7342
+ return {
7343
+ flags,
7344
+ assist: offTrack ? signalFlag("V") : null,
7345
+ label: hoistLabel(key, flags, offTrack)
7346
+ };
7347
+ }
7348
+
6821
7349
  // src/tui/ui.tsx
6822
7350
  /**
6823
7351
  * The terminal app's small parts: a one-line text field, a list to pick
@@ -7002,6 +7530,33 @@ function Heading({ children, right }) {
7002
7530
  }) : null]
7003
7531
  });
7004
7532
  }
7533
+ /** The signal flags' five colours as a terminal draws them. */
7534
+ const FLAG_TERM = {
7535
+ r: "#d0533a",
7536
+ b: "#2f62a8",
7537
+ y: "#e8b230",
7538
+ k: "#1f1e1d",
7539
+ w: "#f3f1ea"
7540
+ };
7541
+ /**
7542
+ * A project's key in signal flags, one cell a flag: the left half the
7543
+ * hoist's colour, the right half the fly's (Victor after a gap when the
7544
+ * project is off track). Rough, but recognisably the same emblem.
7545
+ */
7546
+ function FlagHoist({ code, health }) {
7547
+ const hoist = projectHoist(code, health === "off-track");
7548
+ if (!hoist) return null;
7549
+ const all = hoist.assist ? [
7550
+ ...hoist.flags,
7551
+ null,
7552
+ hoist.assist
7553
+ ] : hoist.flags;
7554
+ return /* @__PURE__ */ jsx(Text, { children: all.map((f, i) => f ? /* @__PURE__ */ jsx(Text, {
7555
+ color: FLAG_TERM[f.halves[0]],
7556
+ backgroundColor: FLAG_TERM[f.halves[1]],
7557
+ children: "▌"
7558
+ }, i) : /* @__PURE__ */ jsx(Text, { children: " " }, i)) });
7559
+ }
7005
7560
 
7006
7561
  // src/tui/forms.tsx
7007
7562
  /**
@@ -9581,11 +10136,11 @@ function hillText(project) {
9581
10136
  if (at === 100) return "Over the hill: done";
9582
10137
  return `${HILL_PHASE_LABEL[hillPhase(at)]} (${at} of 100)`;
9583
10138
  }
9584
- var HILL_TOP, STILL_DAYS, HILL_PHASE_LABEL;
10139
+ var HILL_TOP, STILL_DAYS$1, HILL_PHASE_LABEL;
9585
10140
  var init_hill = __esmMin((() => {
9586
10141
  init_date_fns();
9587
10142
  HILL_TOP = 50;
9588
- STILL_DAYS = 14;
10143
+ STILL_DAYS$1 = 14;
9589
10144
  HILL_PHASE_LABEL = {
9590
10145
  figuring: "Figuring it out",
9591
10146
  doing: "Getting it done"
@@ -14252,6 +14807,12 @@ function taskMinutes(task) {
14252
14807
  function entriesNewestFirst(task) {
14253
14808
  return [...task.time ?? []].sort((a, b) => (b.on + b.at).localeCompare(a.on + a.at));
14254
14809
  }
14810
+ /** The ISO day `n` days before `iso`. */
14811
+ function daysBefore(iso, n) {
14812
+ const d = /* @__PURE__ */ new Date(`${iso}T12:00:00Z`);
14813
+ d.setUTCDate(d.getUTCDate() - n);
14814
+ return d.toISOString().slice(0, 10);
14815
+ }
14255
14816
 
14256
14817
  // src/lib/goals.ts
14257
14818
  init_indexes();
@@ -20228,8 +20789,8 @@ init_date_fns();
20228
20789
  init_blockers();
20229
20790
  init_format();
20230
20791
  init_indexes();
20231
- const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
20232
- const days$2 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
20792
+ const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
20793
+ const days$2 = (from, to) => differenceInCalendarDays(noon$1(to), noon$1(from));
20233
20794
  const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
20234
20795
  const open = (t) => t.status !== "done";
20235
20796
  /** True when `a` finishes later than `b`, or as late behind a longer line. */
@@ -21260,8 +21821,349 @@ var init_fog = __esmMin((() => {
21260
21821
  fileCache = /* @__PURE__ */ new WeakMap();
21261
21822
  }));
21262
21823
 
21263
- // src/lib/task-approval.ts
21824
+ // src/lib/assignment.ts
21825
+ /**
21826
+ * Record who set a task's assignee and when. Only a change of assignee
21827
+ * stamps; clearing the assignee clears the stamp.
21828
+ */
21829
+ function stampAssignment(before, next, by, at) {
21830
+ if (before && before.assigneeId === next.assigneeId) return next;
21831
+ if (!next.assigneeId) return {
21832
+ ...next,
21833
+ assignedBy: null,
21834
+ assignedAt: null
21835
+ };
21836
+ return {
21837
+ ...next,
21838
+ assignedBy: by,
21839
+ assignedAt: at
21840
+ };
21841
+ }
21842
+ var init_assignment = __esmMin((() => {}));
21843
+
21844
+ // src/lib/scuttle-rows.ts
21845
+ /** Whether it's scuttled: closed through Scuttle or sail and not raised since. */
21846
+ function isScuttled(project) {
21847
+ return Boolean(project.scuttled) && project.healthOverride === "done";
21848
+ }
21849
+ /**
21850
+ * Scuttle a project: Done by hand with the reason as its note, its open
21851
+ * tasks unassigned and undated (kept in `scuttled.freed`), and a decision on
21852
+ * the register. Null for a project that's already scuttled or gone, or a
21853
+ * blank why.
21854
+ */
21855
+ function scuttleRows(data, projectId, why, byId, at, decisionId, context = "Closed through Scuttle or sail.") {
21856
+ const project = data.projects.find((p) => p.id === projectId);
21857
+ const reason = why.trim().slice(0, 600);
21858
+ if (!project || !reason || isScuttled(project)) return null;
21859
+ const freed = [];
21860
+ const tasks = [];
21861
+ for (const t of tasksOf(data.tasks, projectId)) {
21862
+ if (t.status === "done" || !t.assigneeId && !t.dueDate) continue;
21863
+ freed.push({
21864
+ taskId: t.id,
21865
+ assigneeId: t.assigneeId,
21866
+ dueDate: t.dueDate
21867
+ });
21868
+ tasks.push(stampAssignment(t, {
21869
+ ...t,
21870
+ assigneeId: null,
21871
+ dueDate: null,
21872
+ touchedAt: at
21873
+ }, byId, at));
21874
+ }
21875
+ const decision = normalizeRaidItem({
21876
+ id: decisionId,
21877
+ kind: "decision",
21878
+ projectId,
21879
+ title: `Scuttle ${project.name}`,
21880
+ description: context,
21881
+ status: "decided",
21882
+ severity: "low",
21883
+ ownerId: byId,
21884
+ dueDate: null,
21885
+ resolution: "Stop the project. Its open tasks come off people’s lists.",
21886
+ rationale: reason,
21887
+ decidedById: byId,
21888
+ decidedAt: at,
21889
+ createdById: byId,
21890
+ createdAt: at,
21891
+ updatedAt: at,
21892
+ updates: []
21893
+ });
21894
+ return {
21895
+ project: {
21896
+ ...project,
21897
+ health: "done",
21898
+ healthOverride: "done",
21899
+ healthNote: `Scuttled: ${reason}`,
21900
+ scuttled: {
21901
+ at,
21902
+ byId,
21903
+ why: reason,
21904
+ decisionId,
21905
+ freed
21906
+ }
21907
+ },
21908
+ tasks,
21909
+ decision
21910
+ };
21911
+ }
21912
+ /**
21913
+ * Raise a scuttled project: the scuttle cleared (the caller clears its Done), and each
21914
+ * freed task given back its person and date, unless someone has since given
21915
+ * it others, it's done, or the person has left the crew. Null when it isn't
21916
+ * scuttled.
21917
+ */
21918
+ function raiseRows(data, projectId, at, byId) {
21919
+ const project = data.projects.find((p) => p.id === projectId);
21920
+ if (!project?.scuttled) return null;
21921
+ const people = new Set(data.people.map((p) => p.id));
21922
+ const byTask = new Map(project.scuttled.freed.map((f) => [f.taskId, f]));
21923
+ const tasks = [];
21924
+ for (const t of tasksOf(data.tasks, projectId)) {
21925
+ const f = byTask.get(t.id);
21926
+ if (!f || t.status === "done") continue;
21927
+ const assigneeId = !t.assigneeId && f.assigneeId && people.has(f.assigneeId) ? f.assigneeId : t.assigneeId;
21928
+ const dueDate = t.dueDate ?? f.dueDate;
21929
+ if (assigneeId === t.assigneeId && dueDate === t.dueDate) continue;
21930
+ tasks.push(stampAssignment(t, {
21931
+ ...t,
21932
+ assigneeId,
21933
+ dueDate,
21934
+ touchedAt: at
21935
+ }, byId, at));
21936
+ }
21937
+ return {
21938
+ project: {
21939
+ ...project,
21940
+ scuttled: null,
21941
+ voyageCall: {
21942
+ call: "sail",
21943
+ at,
21944
+ byId
21945
+ }
21946
+ },
21947
+ tasks
21948
+ };
21949
+ }
21950
+ var WHY_MAX;
21951
+ var init_scuttle_rows = __esmMin((() => {
21952
+ init_assignment();
21953
+ init_indexes();
21954
+ init_raid();
21955
+ WHY_MAX = 600;
21956
+ }));
21957
+
21958
+ // src/lib/scuttle.ts
21959
+ init_date_fns();
21264
21960
  init_fog();
21961
+ init_indexes();
21962
+ init_scuttle_rows();
21963
+ init_spend();
21964
+ init_format();
21965
+ /** Days with nothing moving before a project reads as still. */
21966
+ const STILL_DAYS = 28;
21967
+ /** A project younger than this is left alone: it's still being set up. */
21968
+ const YOUNG_DAYS = 21;
21969
+ /** Sailing on or a refit keeps it out of the line-up this long. */
21970
+ const QUIET_DAYS = 90;
21971
+ /** Slips are counted over this window. */
21972
+ const SLIP_WINDOW_DAYS = 90;
21973
+ /** This many falls into trouble in the window reads as slipping again and again. */
21974
+ const SLIP_TIMES = 3;
21975
+ /** Its end date this far past the baseline reads as drifting. */
21976
+ const DRIFT_DAYS = 21;
21977
+ /** This far past its end date with work open. */
21978
+ const OVERDUE_DAYS = 14;
21979
+ /** Paused this long. */
21980
+ const PAUSED_DAYS = 30;
21981
+ const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
21982
+ const daysSince = (iso, today) => differenceInCalendarDays(noon(today), noon(iso));
21983
+ const TRACK = {
21984
+ "on-track": 0,
21985
+ "at-risk": 1,
21986
+ "off-track": 2
21987
+ };
21988
+ /** When anything last moved on the project: its tasks (as fog reads them) and its hill dot. */
21989
+ function lastMoved(project, data) {
21990
+ let last = project.hillMovedAt ?? null;
21991
+ for (const t of tasksOf(data.tasks, project.id)) {
21992
+ const at = lastTouch(t, data);
21993
+ if (!last || at > last) last = at;
21994
+ }
21995
+ return last;
21996
+ }
21997
+ /** Times its health fell (on track to at risk or off track, at risk to off track) since `since`. */
21998
+ function slipsSince(project, since) {
21999
+ let times = 0;
22000
+ let prev;
22001
+ for (const line of project.healthHistory ?? []) {
22002
+ const rank = TRACK[line.health];
22003
+ if (rank === void 0) {
22004
+ prev = void 0;
22005
+ continue;
22006
+ }
22007
+ if (prev !== void 0 && rank > prev && line.at.slice(0, 10) >= since) times += 1;
22008
+ prev = rank;
22009
+ }
22010
+ return times;
22011
+ }
22012
+ /** Days it has been paused, from the history's latest line saying so; null when it isn't, or nobody knows since when. */
22013
+ function pausedDays(project, today) {
22014
+ if (project.health !== "paused") return null;
22015
+ const history = project.healthHistory ?? [];
22016
+ let since = null;
22017
+ for (let i = history.length - 1; i >= 0; i--) {
22018
+ if (history[i].health !== "paused") break;
22019
+ since = history[i].at;
22020
+ }
22021
+ return since ? Math.max(0, daysSince(since, today)) : null;
22022
+ }
22023
+ /** A goal's work key result names it or its program. Only asked when its portfolio has goals at all. */
22024
+ function aimless(project, data) {
22025
+ const program = data.programs.find((p) => p.id === project.programId);
22026
+ const goals = (program && data.portfolios.find((p) => p.id === program.portfolioId))?.goals ?? [];
22027
+ if (goals.length === 0) return false;
22028
+ return !goals.some((g) => g.keyResults.some((kr) => kr.kind === "work" && (kr.linkIds ?? []).some((id) => id === project.id || id === project.programId)));
22029
+ }
22030
+ const STRENGTH = {
22031
+ overdue: 0,
22032
+ still: 1,
22033
+ empty: 1,
22034
+ paused: 2,
22035
+ slipping: 3,
22036
+ drifted: 4,
22037
+ aimless: 5
22038
+ };
22039
+ /** The signs that put a project in the line-up today, strongest first; empty when it's sailing fine. */
22040
+ function zombieSigns(project, data, today) {
22041
+ const signs = [];
22042
+ const tasks = tasksOf(data.tasks, project.id);
22043
+ const open = tasks.filter((t) => t.status !== "done").length;
22044
+ const age = daysSince(project.startDate, today);
22045
+ if (tasks.length === 0) {
22046
+ if (age >= 28) signs.push({
22047
+ kind: "empty",
22048
+ days: age
22049
+ });
22050
+ } else if (project.health !== "paused") {
22051
+ const moved = lastMoved(project, data);
22052
+ const still = moved ? daysSince(moved, today) : age;
22053
+ if (still >= 28) signs.push({
22054
+ kind: "still",
22055
+ days: still
22056
+ });
22057
+ }
22058
+ const paused = pausedDays(project, today);
22059
+ if (paused !== null && paused >= 30) signs.push({
22060
+ kind: "paused",
22061
+ days: paused
22062
+ });
22063
+ const late = daysSince(project.endDate, today);
22064
+ if (open > 0 && late >= 14 && project.health !== "paused") signs.push({
22065
+ kind: "overdue",
22066
+ days: late,
22067
+ open
22068
+ });
22069
+ const slips = slipsSince(project, daysBefore(today, 90));
22070
+ if (slips >= 3) signs.push({
22071
+ kind: "slipping",
22072
+ times: slips
22073
+ });
22074
+ if (project.baselineEnd) {
22075
+ const drift = differenceInCalendarDays(noon(project.endDate), noon(project.baselineEnd));
22076
+ if (drift >= 21) signs.push({
22077
+ kind: "drifted",
22078
+ days: drift
22079
+ });
22080
+ }
22081
+ if (signs.length > 0 && aimless(project, data)) signs.push({ kind: "aimless" });
22082
+ return signs.sort((a, b) => STRENGTH[a.kind] - STRENGTH[b.kind]);
22083
+ }
22084
+ /** Left out of the line-up: finished, scuttled, too young, or answered within the quarter. */
22085
+ function restingFromReview(project, today) {
22086
+ if (project.health === "done" || isScuttled(project)) return true;
22087
+ if (project.startDate > today || daysSince(project.startDate, today) < 21) return true;
22088
+ const call = project.voyageCall;
22089
+ return Boolean(call && daysSince(call.at, today) < 90);
22090
+ }
22091
+ /** What scuttling it would free: open tasks by person, logged hours and spend a week. */
22092
+ function whatItHolds(project, data, now) {
22093
+ const today = todayISO(now);
22094
+ const tasks = tasksOf(data.tasks, project.id);
22095
+ const by = /* @__PURE__ */ new Map();
22096
+ let openTasks = 0;
22097
+ let minutes = 0;
22098
+ const from = daysBefore(today, 27);
22099
+ for (const t of tasks) {
22100
+ for (const e of t.time ?? []) if (e.on >= from && e.on <= today) minutes += e.minutes;
22101
+ if (t.status === "done") continue;
22102
+ openTasks += 1;
22103
+ if (t.assigneeId) by.set(t.assigneeId, (by.get(t.assigneeId) ?? 0) + 1);
22104
+ }
22105
+ const crew = [...by].map(([personId, open]) => ({
22106
+ personId,
22107
+ open
22108
+ })).sort((a, b) => b.open - a.open);
22109
+ const burn = project.budget > 0 ? burnPerDay(spendEntriesOf({ spendEntries: data.spendEntries ?? [] }, project.id), project.startDate, now) : null;
22110
+ return {
22111
+ openTasks,
22112
+ crew,
22113
+ hoursPerWeek: minutes > 0 ? Math.round(minutes / 60 / 4 * 10) / 10 : null,
22114
+ spendPerWeek: burn ? Math.round(burn * 7) : null
22115
+ };
22116
+ }
22117
+ /**
22118
+ * The line-up: projects showing a zombie sign that aren't resting, the ones
22119
+ * with the most signs first, then the stillest. `projectIds` narrows it to a
22120
+ * portfolio's or program's; leave it out for the whole harbor.
22121
+ */
22122
+ function scuttleLineUp(data, now, projectIds) {
22123
+ const today = todayISO(now);
22124
+ const out = [];
22125
+ for (const project of data.projects) {
22126
+ if (projectIds && !projectIds.has(project.id)) continue;
22127
+ if (restingFromReview(project, today)) continue;
22128
+ const signs = zombieSigns(project, data, today);
22129
+ if (signs.length === 0) continue;
22130
+ out.push({
22131
+ project,
22132
+ signs,
22133
+ lastMoved: lastMoved(project, data),
22134
+ ...whatItHolds(project, data, now)
22135
+ });
22136
+ }
22137
+ const still = (z) => {
22138
+ const s = z.signs.find((x) => x.kind === "still" || x.kind === "empty");
22139
+ return s && "days" in s ? s.days : 0;
22140
+ };
22141
+ return out.sort((a, b) => b.signs.length - a.signs.length || still(b) - still(a) || a.project.name.localeCompare(b.project.name));
22142
+ }
22143
+ /** The projects in a portfolio or a program, for narrowing the line-up. */
22144
+ function projectIdsUnder(data, scope) {
22145
+ const programs = new Set(scope.programId ? [scope.programId] : data.programs.filter((p) => p.portfolioId === scope.portfolioId).map((p) => p.id));
22146
+ return new Set(data.projects.filter((p) => programs.has(p.programId)).map((p) => p.id));
22147
+ }
22148
+ /** "6 weeks", "10 days". */
22149
+ function spanText(days) {
22150
+ if (days >= 14) return `${Math.floor(days / 7)} weeks`;
22151
+ return `${days} day${days === 1 ? "" : "s"}`;
22152
+ }
22153
+ /** One sign in a short sentence. */
22154
+ function signText(sign) {
22155
+ switch (sign.kind) {
22156
+ case "still": return `Nothing has moved in ${spanText(sign.days)}`;
22157
+ case "empty": return `No tasks, ${spanText(sign.days)} after it started`;
22158
+ case "slipping": return `Slipped into trouble ${sign.times} times in three months`;
22159
+ case "drifted": return `Its end date has moved ${spanText(sign.days)} past the plan`;
22160
+ case "overdue": return `${spanText(sign.days)} past its end date, ${sign.open} task${sign.open === 1 ? "" : "s"} still open`;
22161
+ case "paused": return `Paused for ${spanText(sign.days)}`;
22162
+ case "aimless": return "No goal behind it";
22163
+ }
22164
+ }
22165
+
22166
+ // src/lib/task-approval.ts
21265
22167
  /** Waits on its approver now: asked, not yet answered, and not done. */
21266
22168
  function awaitingApproval(task) {
21267
22169
  return Boolean(task?.approval && task.approval.status === "pending" && task.status !== "done");
@@ -21298,6 +22200,7 @@ init_handover();
21298
22200
  init_agents();
21299
22201
  init_work();
21300
22202
  init_task_people();
22203
+ init_fog();
21301
22204
  init_passage();
21302
22205
  init_board();
21303
22206
  init_holidays();
@@ -23138,6 +24041,53 @@ const HARBOR_TOOLS = [
23138
24041
  };
23139
24042
  }
23140
24043
  }),
24044
+ tool({
24045
+ name: "scuttle_or_sail",
24046
+ title: "Projects that look adrift",
24047
+ description: "Scuttle or sail: running projects that look like zombies (nothing moved for four weeks, no tasks a month in, two weeks past the end with work open, paused for a month, slipped into trouble three times in three months, or the end date moved three weeks past the baseline; \"no goal behind it\" is added when the portfolio keeps goals), the most signs first. Each says what stopping it would free: open tasks by person, logged hours a week, spend a week. Projects someone kept or refitted in the last three months, finished ones and ones under three weeks old are left out. Walk the person through them one at a time; answer_scuttle_or_sail records sail on, refit or scuttle, only on their word." + DATA_NOTE,
24048
+ input: object({
24049
+ harbor: harborArg,
24050
+ portfolio: string().max(200).optional().describe("Portfolio id or name, for its projects only."),
24051
+ program: string().max(200).optional().describe("Program id or name, for its projects only."),
24052
+ limit: limitArg(100, 25),
24053
+ offset: offsetArg
24054
+ }),
24055
+ run: (ctx, input) => {
24056
+ const { data } = ctx;
24057
+ const program = input.program ? findOne(data.programs, input.program, "program") : null;
24058
+ const portfolio = !program && input.portfolio ? findOne(data.portfolios, input.portfolio, "portfolio") : null;
24059
+ const ids = program || portfolio ? projectIdsUnder(data, {
24060
+ programId: program?.id,
24061
+ portfolioId: portfolio?.id
24062
+ }) : void 0;
24063
+ const lineUp = scuttleLineUp(data, ctx.now, ids);
24064
+ const people = byId(data.people);
24065
+ return {
24066
+ ...program ? { program: program.name } : portfolio ? { portfolio: portfolio.name } : {},
24067
+ adrift: lineUp.length,
24068
+ projects: lineUp.slice(input.offset, input.offset + input.limit).map((z) => ({
24069
+ project: z.project.name,
24070
+ ...z.project.key ? { key: z.project.key } : {},
24071
+ health: z.project.health,
24072
+ endDate: z.project.endDate,
24073
+ owner: people.get(z.project.ownerId)?.name ?? null,
24074
+ signs: z.signs.map((s) => ({
24075
+ kind: s.kind,
24076
+ text: signText(s)
24077
+ })),
24078
+ lastMoved: dayOf(z.lastMoved),
24079
+ openTasks: z.openTasks,
24080
+ crew: z.crew.map((c) => ({
24081
+ person: people.get(c.personId)?.name ?? null,
24082
+ openTasks: c.open
24083
+ })),
24084
+ hoursPerWeek: z.hoursPerWeek,
24085
+ spendPerWeek: z.spendPerWeek
24086
+ })),
24087
+ more: lineUp.length > input.offset + input.limit
24088
+ };
24089
+ }
24090
+ }),
23141
24091
  tool({
23142
24092
  name: "lifeboats",
23143
24093
  title: "Work only one person knows",
@@ -23932,26 +24882,6 @@ var init_session = __esmMin((() => {
23932
24882
  init_format();
23933
24883
  }));
23934
24884
 
23935
- // src/lib/assignment.ts
23936
- /**
23937
- * Record who set a task's assignee and when. Only a change of assignee
23938
- * stamps; clearing the assignee clears the stamp.
23939
- */
23940
- function stampAssignment(before, next, by, at) {
23941
- if (before && before.assigneeId === next.assigneeId) return next;
23942
- if (!next.assigneeId) return {
23943
- ...next,
23944
- assignedBy: null,
23945
- assignedAt: null
23946
- };
23947
- return {
23948
- ...next,
23949
- assignedBy: by,
23950
- assignedAt: at
23951
- };
23952
- }
23953
- var init_assignment = __esmMin((() => {}));
23954
-
23955
24885
  // src/lib/first-week.ts
23956
24886
  /** The program launch named for you, when nobody has renamed it yet. */
23957
24887
  function starterProgram(data) {
@@ -23963,6 +24893,33 @@ var init_first_week = __esmMin((() => {
23963
24893
 
23964
24894
  // src/lib/slices/hierarchy.ts
23965
24895
  function hierarchySlice({ get, write }) {
24896
+ /**
24897
+ * A scuttled project being reopened (its Done cleared or changed): its
24898
+ * freed tasks get their people and dates back, and its decision says so.
24899
+ * Null when it isn't scuttled.
24900
+ */
24901
+ const raise = (projectId, at) => {
24902
+ const s = get();
24903
+ const rows = raiseRows(s, projectId, at, s.currentUserId);
24904
+ if (!rows) return null;
24905
+ const changed = new Map(rows.tasks.map((t) => [t.id, t]));
24906
+ const decisionId = s.projects.find((p) => p.id === projectId)?.scuttled?.decisionId;
24907
+ const note = {
24908
+ id: uid("ru"),
24909
+ authorId: s.currentUserId,
24910
+ body: "Raised again: the project is back, and its tasks went back to their people.",
24911
+ createdAt: at
24912
+ };
24913
+ return {
24914
+ projects: s.projects.map((p) => p.id === projectId ? rows.project : p),
24915
+ tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
24916
+ raidItems: s.raidItems.map((r) => r.id === decisionId ? {
24917
+ ...r,
24918
+ updates: [...r.updates, note],
24919
+ updatedAt: at
24920
+ } : r)
24921
+ };
24922
+ };
23966
24923
  return {
23967
24924
  addPortfolio: (p) => {
23968
24925
  const id = uid("pf");
@@ -24207,11 +25164,24 @@ function hierarchySlice({ get, write }) {
24207
25164
  programs: get().programs.map((p) => p.id === id ? mark(p) : p),
24208
25165
  projects: parked ? get().projects.map((p) => p.programId === id && (!p.healthOverride || p.healthOverride === "paused" || p.healthOverride === "done") ? mark(p) : p) : get().projects
24209
25166
  });
24210
- } else write({ projects: get().projects.map((p) => p.id === id ? mark(p) : p) });
25167
+ } else {
25168
+ const raised = health === "done" ? null : raise(id, at);
25169
+ write({
25170
+ projects: (raised?.projects ?? get().projects).map((p) => p.id === id ? mark(p) : p),
25171
+ ...raised ? {
25172
+ tasks: raised.tasks,
25173
+ raidItems: raised.raidItems
25174
+ } : {}
25175
+ });
25176
+ }
24211
25177
  },
24212
25178
  clearHealthOverride: (kind, id) => {
24213
25179
  const at = stamp();
24214
- const s = get();
25180
+ const raised = kind === "project" ? raise(id, at) : null;
25181
+ const s = raised ? {
25182
+ ...get(),
25183
+ ...raised
25184
+ } : get();
24215
25185
  const cleared = {
24216
25186
  portfolio: /* @__PURE__ */ new Set(),
24217
25187
  program: /* @__PURE__ */ new Set(),
@@ -24246,9 +25216,59 @@ function hierarchySlice({ get, write }) {
24246
25216
  write({
24247
25217
  portfolios: next.portfolios.map(byRules("portfolio")),
24248
25218
  programs: next.programs.map(byRules("program")),
24249
- projects: next.projects.map(byRules("project"))
25219
+ projects: next.projects.map(byRules("project")),
25220
+ ...raised ? {
25221
+ tasks: raised.tasks,
25222
+ raidItems: raised.raidItems
25223
+ } : {}
24250
25224
  });
24251
25225
  },
25226
+ scuttleProject: (projectId, why, context) => {
25227
+ const s = get();
25228
+ const at = stamp();
25229
+ const decisionId = uid("raid");
25230
+ const rows = scuttleRows(s, projectId, why, s.currentUserId, at, decisionId, context);
25231
+ if (!rows) return null;
25232
+ const before = s.projects.find((p) => p.id === projectId);
25233
+ const changed = new Map(rows.tasks.map((t) => [t.id, t]));
25234
+ write({
25235
+ projects: s.projects.map((p) => p.id === projectId ? {
25236
+ ...rows.project,
25237
+ healthHistory: recordHealth(p.healthHistory, "done", at, true, rows.project.healthNote)
25238
+ } : p),
25239
+ tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
25240
+ raidItems: [...s.raidItems, rows.decision]
25241
+ });
25242
+ get().log(`scuttled ${before.name}`, `/projects/${projectId}`);
25243
+ return {
25244
+ project: before,
25245
+ tasks: s.tasks.filter((t) => changed.has(t.id)),
25246
+ decisionId
25247
+ };
25248
+ },
25249
+ undoScuttle: (undo) => {
25250
+ const s = get();
25251
+ const back = new Map(undo.tasks.map((t) => [t.id, t]));
25252
+ write({
25253
+ projects: s.projects.map((p) => p.id === undo.project.id ? undo.project : p),
25254
+ tasks: s.tasks.map((t) => back.get(t.id) ?? t),
25255
+ raidItems: s.raidItems.filter((r) => r.id !== undo.decisionId)
25256
+ });
25257
+ },
25258
+ answerVoyage: (projectId, call, note) => {
25259
+ const at = stamp();
25260
+ const byId = get().currentUserId;
25261
+ const text = note?.trim().slice(0, 300);
25262
+ write({ projects: get().projects.map((p) => p.id === projectId ? {
25263
+ ...p,
25264
+ voyageCall: {
25265
+ call,
25266
+ at,
25267
+ byId,
25268
+ ...text ? { note: text } : {}
25269
+ }
25270
+ } : p) });
25271
+ },
24252
25272
  snapshotBaseline: (programId, reset = false) => {
24253
25273
  const inProgram = new Set(get().projects.filter((p) => p.programId === programId).map((p) => p.id));
24254
25274
  const stamped = withBaseline(get().projects.filter((p) => inProgram.has(p.id)), reset, stamp());
@@ -24406,6 +25426,7 @@ var init_hierarchy = __esmMin((() => {
24406
25426
  init_derived_health();
24407
25427
  init_shared();
24408
25428
  init_assignment();
25429
+ init_scuttle_rows();
24409
25430
  init_hill();
24410
25431
  init_charter_lines();
24411
25432
  init_format();
@@ -30174,6 +31195,11 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
30174
31195
  children: project.key
30175
31196
  }),
30176
31197
  " ",
31198
+ /* @__PURE__ */ jsx(FlagHoist, {
31199
+ code: project.key,
31200
+ health: project.health
31201
+ }),
31202
+ " ",
30177
31203
  /* @__PURE__ */ jsx(HealthMark, {
30178
31204
  health: project.health,
30179
31205
  label: true