@harborppm/cli 0.94.0 → 0.96.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 +697 -5
- package/lib/cli.mjs +27 -3
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -5779,7 +5779,7 @@ function matches$1(row, f) {
|
|
|
5779
5779
|
if (f.hideDone && row.health === "done") return false;
|
|
5780
5780
|
return true;
|
|
5781
5781
|
}
|
|
5782
|
-
function milestoneOf(m, today) {
|
|
5782
|
+
function milestoneOf$1(m, today) {
|
|
5783
5783
|
return {
|
|
5784
5784
|
id: m.id,
|
|
5785
5785
|
name: m.name,
|
|
@@ -5818,7 +5818,7 @@ function roadmapRows(data, portfolioId, filter, collapsed, today) {
|
|
|
5818
5818
|
baselineEnd: null,
|
|
5819
5819
|
slip: null,
|
|
5820
5820
|
waitsOn: blockerIds(program).filter((id) => programIds.has(id)),
|
|
5821
|
-
milestones: data.milestones.filter((m) => m.programId === program.id && !(filter.hideDone && m.done)).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).map((m) => milestoneOf(m, today)),
|
|
5821
|
+
milestones: data.milestones.filter((m) => m.programId === program.id && !(filter.hideDone && m.done)).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).map((m) => milestoneOf$1(m, today)),
|
|
5822
5822
|
projectCount: projects.length
|
|
5823
5823
|
});
|
|
5824
5824
|
if (collapsed.has(program.id)) continue;
|
|
@@ -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
|
/**
|
|
@@ -14121,6 +14676,107 @@ function applyDock(actions, projectId, changes) {
|
|
|
14121
14676
|
return made;
|
|
14122
14677
|
}
|
|
14123
14678
|
|
|
14679
|
+
// src/lib/task-milestones.ts
|
|
14680
|
+
/** The milestone a task is tied to, or null when it isn't (or the tie no longer holds). */
|
|
14681
|
+
function milestoneOf(task, milestones, projects) {
|
|
14682
|
+
if (!task.milestoneId) return null;
|
|
14683
|
+
const m = byId(milestones).get(task.milestoneId);
|
|
14684
|
+
if (!m) return null;
|
|
14685
|
+
const project = byId(projects).get(task.projectId);
|
|
14686
|
+
return project && project.programId === m.programId ? m : null;
|
|
14687
|
+
}
|
|
14688
|
+
/** For each milestone with tasks tied to it, how many there are and how many are done. One pass. */
|
|
14689
|
+
function milestoneProgress(doc) {
|
|
14690
|
+
const out = /* @__PURE__ */ new Map();
|
|
14691
|
+
if (doc.milestones.length === 0) return out;
|
|
14692
|
+
for (const t of doc.tasks) {
|
|
14693
|
+
if (!t.milestoneId) continue;
|
|
14694
|
+
const m = milestoneOf(t, doc.milestones, doc.projects);
|
|
14695
|
+
if (!m) continue;
|
|
14696
|
+
const p = out.get(m.id) ?? {
|
|
14697
|
+
done: 0,
|
|
14698
|
+
total: 0
|
|
14699
|
+
};
|
|
14700
|
+
p.total++;
|
|
14701
|
+
if (t.status === "done") p.done++;
|
|
14702
|
+
out.set(m.id, p);
|
|
14703
|
+
}
|
|
14704
|
+
return out;
|
|
14705
|
+
}
|
|
14706
|
+
/** Every project it waits on is done (no waits, or a project gone, count as clear). */
|
|
14707
|
+
function waitsClear(m, projects) {
|
|
14708
|
+
const index = byId(projects);
|
|
14709
|
+
return blockerIds(m).every((id) => {
|
|
14710
|
+
const p = index.get(id);
|
|
14711
|
+
return !p || p.health === "done";
|
|
14712
|
+
});
|
|
14713
|
+
}
|
|
14714
|
+
/**
|
|
14715
|
+
* The document after a write, with milestones moved by what happened to their
|
|
14716
|
+
* tasks: a milestone that lost an open task (finished, deleted, untied or
|
|
14717
|
+
* moved away) and now has all its tasks done is reached; a reached one that
|
|
14718
|
+
* gained an open task (reopened, tied, added or moved in) is open again. The
|
|
14719
|
+
* same document back when nothing moved. Only tasks this write changed are
|
|
14720
|
+
* looked at, then one pass counts the milestones they touch.
|
|
14721
|
+
*/
|
|
14722
|
+
function followMilestones(before, after) {
|
|
14723
|
+
if (after.tasks === before.tasks || after.milestones.length === 0) return after;
|
|
14724
|
+
const was = byId(before.tasks);
|
|
14725
|
+
const tie = (t, doc) => milestoneOf(t, doc.milestones, doc.projects)?.id ?? null;
|
|
14726
|
+
const lost = /* @__PURE__ */ new Set();
|
|
14727
|
+
const gained = /* @__PURE__ */ new Set();
|
|
14728
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14729
|
+
for (const t of after.tasks) {
|
|
14730
|
+
const old = was.get(t.id);
|
|
14731
|
+
if (old) seen.add(t.id);
|
|
14732
|
+
if (old === t) continue;
|
|
14733
|
+
const from = old ? tie(old, before) : null;
|
|
14734
|
+
const to = tie(t, after);
|
|
14735
|
+
const wasOpen = old ? old.status !== "done" : false;
|
|
14736
|
+
const isOpen = t.status !== "done";
|
|
14737
|
+
if (from && wasOpen && (from !== to || !isOpen)) lost.add(from);
|
|
14738
|
+
if (to && isOpen && (from !== to || !wasOpen)) gained.add(to);
|
|
14739
|
+
}
|
|
14740
|
+
if (seen.size < before.tasks.length) for (const old of before.tasks) {
|
|
14741
|
+
if (seen.has(old.id) || old.status === "done") continue;
|
|
14742
|
+
const from = tie(old, before);
|
|
14743
|
+
if (from) lost.add(from);
|
|
14744
|
+
}
|
|
14745
|
+
if (lost.size === 0 && gained.size === 0) return after;
|
|
14746
|
+
const progress = milestoneProgress(after);
|
|
14747
|
+
let changed = false;
|
|
14748
|
+
const milestones = after.milestones.map((m) => {
|
|
14749
|
+
if (!follows(m)) return m;
|
|
14750
|
+
const p = progress.get(m.id);
|
|
14751
|
+
if (!m.done && lost.has(m.id) && p && p.total > 0 && p.done === p.total) {
|
|
14752
|
+
if (!waitsClear(m, after.projects)) return m;
|
|
14753
|
+
changed = true;
|
|
14754
|
+
return {
|
|
14755
|
+
...m,
|
|
14756
|
+
done: true
|
|
14757
|
+
};
|
|
14758
|
+
}
|
|
14759
|
+
if (m.done && gained.has(m.id)) {
|
|
14760
|
+
changed = true;
|
|
14761
|
+
return {
|
|
14762
|
+
...m,
|
|
14763
|
+
done: false
|
|
14764
|
+
};
|
|
14765
|
+
}
|
|
14766
|
+
return m;
|
|
14767
|
+
});
|
|
14768
|
+
return changed ? {
|
|
14769
|
+
...after,
|
|
14770
|
+
milestones
|
|
14771
|
+
} : after;
|
|
14772
|
+
}
|
|
14773
|
+
var follows;
|
|
14774
|
+
var init_task_milestones = __esmMin((() => {
|
|
14775
|
+
init_blockers();
|
|
14776
|
+
init_indexes();
|
|
14777
|
+
follows = (m) => !m.approverId;
|
|
14778
|
+
}));
|
|
14779
|
+
|
|
14124
14780
|
// src/lib/time-limits.ts
|
|
14125
14781
|
var MAX_ENTRY_MINUTES;
|
|
14126
14782
|
var init_time_limits = __esmMin((() => {
|
|
@@ -14128,6 +14784,7 @@ var init_time_limits = __esmMin((() => {
|
|
|
14128
14784
|
}));
|
|
14129
14785
|
|
|
14130
14786
|
// src/lib/time-log.ts
|
|
14787
|
+
init_task_milestones();
|
|
14131
14788
|
/** "1h 30m", "45m", "12h". */
|
|
14132
14789
|
function formatMinutes(total) {
|
|
14133
14790
|
const n = Math.max(0, Math.round(total));
|
|
@@ -21947,7 +22604,7 @@ const HARBOR_TOOLS = [
|
|
|
21947
22604
|
tool({
|
|
21948
22605
|
name: "get_task",
|
|
21949
22606
|
title: "Task details",
|
|
21950
|
-
description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), its values for the project's own fields by name (fields, when it has any; set them with set_field), its subtasks or the task it's a subtask of (subtaskOf), and its url on the website." + DATA_NOTE,
|
|
22607
|
+
description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), its values for the project's own fields by name (fields, when it has any; set them with set_field), its subtasks or the task it's a subtask of (subtaskOf), the milestone it's tied to (milestone: its due day, whether it's reached, and how many of its tasks are done of all tied to it), and its url on the website." + DATA_NOTE,
|
|
21951
22608
|
input: object({
|
|
21952
22609
|
harbor: harborArg,
|
|
21953
22610
|
task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
|
|
@@ -22001,6 +22658,12 @@ const HARBOR_TOOLS = [
|
|
|
22001
22658
|
waitingOn: blockersOf(t, data.tasks).map(brief),
|
|
22002
22659
|
waitedOnBy: waiters.map(brief),
|
|
22003
22660
|
...((parent) => parent ? { subtaskOf: brief(parent) } : {})(parentOf(t, data.tasks)),
|
|
22661
|
+
...((m) => m ? { milestone: {
|
|
22662
|
+
name: m.name,
|
|
22663
|
+
due: dayOf(m.dueDate),
|
|
22664
|
+
reached: m.done,
|
|
22665
|
+
...milestoneProgress(data).get(m.id)
|
|
22666
|
+
} } : {})(milestoneOf(t, data.milestones, data.projects)),
|
|
22004
22667
|
...((subs) => subs.length ? { subtasks: subs.map((x) => ({
|
|
22005
22668
|
...brief(x),
|
|
22006
22669
|
assignee: nameOf$2(people, x.assigneeId),
|
|
@@ -24511,6 +25174,7 @@ function nextOccurrence(task, id, today, createdAt, siblings = []) {
|
|
|
24511
25174
|
id,
|
|
24512
25175
|
status: "todo",
|
|
24513
25176
|
dueDate,
|
|
25177
|
+
milestoneId: null,
|
|
24514
25178
|
...task.startDate ? { startDate: lead != null && lead >= 0 ? format(addDays(/* @__PURE__ */ new Date(`${dueDate}T12:00:00`), -lead), "yyyy-MM-dd") : null } : {},
|
|
24515
25179
|
createdAt,
|
|
24516
25180
|
completedAt: null,
|
|
@@ -25962,10 +26626,10 @@ function createAppStore(onNotice = () => {}) {
|
|
|
25962
26626
|
};
|
|
25963
26627
|
const write = (partial) => {
|
|
25964
26628
|
const before = get();
|
|
25965
|
-
guardedSet((s) => stampHealth(numberNewTasks(s, {
|
|
26629
|
+
guardedSet((s) => stampHealth(followMilestones(s, numberNewTasks(s, {
|
|
25966
26630
|
...s,
|
|
25967
26631
|
...partial
|
|
25968
|
-
})));
|
|
26632
|
+
}))));
|
|
25969
26633
|
undo = nextStep(undo, before, get(), Date.now());
|
|
25970
26634
|
};
|
|
25971
26635
|
const api = {
|
|
@@ -26031,6 +26695,7 @@ var init_store = __esmMin((() => {
|
|
|
26031
26695
|
init_vanilla();
|
|
26032
26696
|
init_derived_health();
|
|
26033
26697
|
init_task_keys();
|
|
26698
|
+
init_task_milestones();
|
|
26034
26699
|
init_payload();
|
|
26035
26700
|
init_undo();
|
|
26036
26701
|
init_view_only();
|
|
@@ -30064,6 +30729,11 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
30064
30729
|
children: project.key
|
|
30065
30730
|
}),
|
|
30066
30731
|
" ",
|
|
30732
|
+
/* @__PURE__ */ jsx(FlagHoist, {
|
|
30733
|
+
code: project.key,
|
|
30734
|
+
health: project.health
|
|
30735
|
+
}),
|
|
30736
|
+
" ",
|
|
30067
30737
|
/* @__PURE__ */ jsx(HealthMark, {
|
|
30068
30738
|
health: project.health,
|
|
30069
30739
|
label: true
|
|
@@ -31311,6 +31981,7 @@ init_recurrence();
|
|
|
31311
31981
|
init_task_people();
|
|
31312
31982
|
init_search();
|
|
31313
31983
|
init_swatches();
|
|
31984
|
+
init_task_milestones();
|
|
31314
31985
|
const FIELDS = [
|
|
31315
31986
|
{
|
|
31316
31987
|
field: "title",
|
|
@@ -31360,6 +32031,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
31360
32031
|
const { store, today } = useDeps();
|
|
31361
32032
|
const tasks = useHarbor((s) => s.tasks);
|
|
31362
32033
|
const projects = useHarbor((s) => s.projects);
|
|
32034
|
+
const milestones = useHarbor((s) => s.milestones);
|
|
31363
32035
|
const people = useHarbor((s) => s.people);
|
|
31364
32036
|
const comments = useHarbor((s) => s.comments);
|
|
31365
32037
|
const reflections = useHarbor((s) => s.reflections);
|
|
@@ -31426,6 +32098,12 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
31426
32098
|
const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
|
|
31427
32099
|
const blockers = blockersOf(task, tasks);
|
|
31428
32100
|
const subtaskOf = parentOf(task, tasks);
|
|
32101
|
+
const milestone = milestoneOf(task, milestones, projects);
|
|
32102
|
+
const toward = milestone ? milestoneProgress({
|
|
32103
|
+
tasks,
|
|
32104
|
+
milestones,
|
|
32105
|
+
projects
|
|
32106
|
+
}).get(milestone.id) : void 0;
|
|
31429
32107
|
const subs = subtasksOf(task, tasks);
|
|
31430
32108
|
const done = () => setEditing(null);
|
|
31431
32109
|
const nameOf = (id) => people.find((p) => p.id === id)?.name;
|
|
@@ -31790,6 +32468,20 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
31790
32468
|
subtaskOf.title
|
|
31791
32469
|
]
|
|
31792
32470
|
}) : null,
|
|
32471
|
+
milestone ? /* @__PURE__ */ jsxs(Text, {
|
|
32472
|
+
color: theme.muted,
|
|
32473
|
+
wrap: "truncate-end",
|
|
32474
|
+
children: [
|
|
32475
|
+
" ",
|
|
32476
|
+
"Toward ",
|
|
32477
|
+
milestone.name,
|
|
32478
|
+
" (",
|
|
32479
|
+
milestone.dueDate.slice(0, 10),
|
|
32480
|
+
"):",
|
|
32481
|
+
" ",
|
|
32482
|
+
milestone.done ? "reached" : `${toward?.done ?? 0}/${toward?.total ?? 0} of its tasks done`
|
|
32483
|
+
]
|
|
32484
|
+
}) : null,
|
|
31793
32485
|
subs.length > 0 ? /* @__PURE__ */ jsxs(Text, {
|
|
31794
32486
|
color: theme.muted,
|
|
31795
32487
|
children: [
|
package/lib/cli.mjs
CHANGED
|
@@ -170,6 +170,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
170
170
|
[--priority low|medium|high|urgent]
|
|
171
171
|
[--status S] [--comment TEXT] [--over-limit] [--dry-run]
|
|
172
172
|
[--literal] [--template NAME] [--parent TASK]
|
|
173
|
+
[--milestone NAME]
|
|
173
174
|
reads plain words in TITLE as the
|
|
174
175
|
website does: @name, by friday,
|
|
175
176
|
!high, #project (--literal: keep
|
|
@@ -186,7 +187,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
186
187
|
[--priority P] [--status S] [--notes TEXT]
|
|
187
188
|
[--repeat weekly|biweekly|monthly|never]
|
|
188
189
|
[--waits-on TASK,TASK|none] [--parent TASK|none]
|
|
189
|
-
[--confirm] [--over-limit]
|
|
190
|
+
[--milestone NAME|none] [--confirm] [--over-limit]
|
|
190
191
|
harbor limit PROJECT [COLUMN N|none] how many tasks a board column
|
|
191
192
|
takes at once; the owner sets it
|
|
192
193
|
harbor week [--next|--last|--week DATE] [--review]
|
|
@@ -710,7 +711,7 @@ export function hint(message, project) {
|
|
|
710
711
|
)
|
|
711
712
|
.replace(
|
|
712
713
|
/^Nothing to change: pass a field to update\.$/,
|
|
713
|
-
"Nothing to change. Say what with --title, --assignee, --also, --due, --starts, --priority, --status, --notes, --repeat, --waits-on or --
|
|
714
|
+
"Nothing to change. Say what with --title, --assignee, --also, --due, --starts, --priority, --status, --notes, --repeat, --waits-on, --parent or --milestone.",
|
|
714
715
|
)
|
|
715
716
|
.replace(
|
|
716
717
|
/^This connection was allowed to read only\..*$/s,
|
|
@@ -795,6 +796,11 @@ const DETAIL = {
|
|
|
795
796
|
" own owner and date, shown in the parent's details rather than on the",
|
|
796
797
|
" board. It goes in the parent's project; subtasks go one level deep.",
|
|
797
798
|
"",
|
|
799
|
+
" --milestone NAME ties it to one of the program's milestones: the milestone",
|
|
800
|
+
" is reached once its tasks are done, and opens again when one reopens. The",
|
|
801
|
+
" project's timeline shows each milestone under its tasks. harbor edit TASK",
|
|
802
|
+
" --milestone NAME|none changes or clears it.",
|
|
803
|
+
"",
|
|
798
804
|
" --starts DATE gives it a start day as well as a due day, so the project's",
|
|
799
805
|
" timeline draws it as a bar from one to the other; a start after the due",
|
|
800
806
|
" day comes to the due day. harbor edit TASK --starts DATE|none moves it.",
|
|
@@ -2312,6 +2318,10 @@ export async function run(argv, io = {}) {
|
|
|
2312
2318
|
if (t.alsoOn?.length) lines.push(`Also on it: ${t.alsoOn.join(", ")}.`);
|
|
2313
2319
|
if (t.starts) lines.push(`Starts ${t.starts}${t.due ? `, due ${t.due}` : ""}.`);
|
|
2314
2320
|
if (t.subtaskOf) lines.push(`Subtask of ${t.subtaskOf.key ?? t.subtaskOf.id} ${t.subtaskOf.title}.`);
|
|
2321
|
+
if (t.milestone)
|
|
2322
|
+
lines.push(
|
|
2323
|
+
`Toward the milestone ${t.milestone.name} (${t.milestone.due}): ${t.milestone.reached ? "reached" : `${t.milestone.done} of ${t.milestone.total} of its tasks done`}.`,
|
|
2324
|
+
);
|
|
2315
2325
|
if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
|
|
2316
2326
|
for (const [name, v] of Object.entries(t.fields ?? {}))
|
|
2317
2327
|
lines.push(`${name}: ${Array.isArray(v) ? v.join(", ") : v === true ? "yes" : v}`);
|
|
@@ -2564,7 +2574,7 @@ export async function run(argv, io = {}) {
|
|
|
2564
2574
|
const { positionals, values, harbor } = parse(
|
|
2565
2575
|
spec(
|
|
2566
2576
|
["over-limit", "dry-run", "literal"],
|
|
2567
|
-
["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent"],
|
|
2577
|
+
["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent", "milestone"],
|
|
2568
2578
|
),
|
|
2569
2579
|
);
|
|
2570
2580
|
// A subtask goes in its parent's project.
|
|
@@ -2674,6 +2684,7 @@ export async function run(argv, io = {}) {
|
|
|
2674
2684
|
priority: values.priority ?? words.priority?.value,
|
|
2675
2685
|
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
2676
2686
|
parent: values.parent,
|
|
2687
|
+
milestone: values.milestone,
|
|
2677
2688
|
overLimit: values["over-limit"] || undefined,
|
|
2678
2689
|
})
|
|
2679
2690
|
.catch((err) => {
|
|
@@ -2712,6 +2723,8 @@ export async function run(argv, io = {}) {
|
|
|
2712
2723
|
...(dueWords(values.due) ? [`--due ${values.due}: ${dayWithWeekday(dueDate(values.due))}.`] : []),
|
|
2713
2724
|
// Pilot speaks up only when a date was given that the queue says won't hold.
|
|
2714
2725
|
...(values.due ? pilotLines(r, r.created) : []),
|
|
2726
|
+
...(r.created.milestone ? [`Toward the milestone ${r.created.milestone}.`] : []),
|
|
2727
|
+
...(r.milestoneReopened ? [`${r.milestoneReopened} is open again: it has an open task now.`] : []),
|
|
2715
2728
|
].join("\n"),
|
|
2716
2729
|
)
|
|
2717
2730
|
.join("\n"),
|
|
@@ -2777,6 +2790,8 @@ export async function run(argv, io = {}) {
|
|
|
2777
2790
|
[
|
|
2778
2791
|
`${r.updated.title}: ${r.reopened ? "reopened, " : ""}${{ doing: "started", review: "in review", done: "done" }[status]}.`,
|
|
2779
2792
|
r.queuedNext ? `Next one: ${taskLine(r.queuedNext, opts)}` : "",
|
|
2793
|
+
r.milestoneReached ? `That was the last of its tasks: ${r.milestoneReached} is reached.` : "",
|
|
2794
|
+
r.milestoneReopened ? `${r.milestoneReopened} is open again: one of its tasks is open.` : "",
|
|
2780
2795
|
r.projectFinished
|
|
2781
2796
|
? `That was the last open task in ${r.projectFinished}. Mark the project Done in the app (harbor) or on the website.`
|
|
2782
2797
|
: "",
|
|
@@ -2801,6 +2816,7 @@ export async function run(argv, io = {}) {
|
|
|
2801
2816
|
"repeat",
|
|
2802
2817
|
"waits-on",
|
|
2803
2818
|
"parent",
|
|
2819
|
+
"milestone",
|
|
2804
2820
|
],
|
|
2805
2821
|
),
|
|
2806
2822
|
);
|
|
@@ -2833,6 +2849,7 @@ export async function run(argv, io = {}) {
|
|
|
2833
2849
|
.map((x) => x.trim())
|
|
2834
2850
|
.filter(Boolean),
|
|
2835
2851
|
parent: values.parent,
|
|
2852
|
+
milestone: values.milestone,
|
|
2836
2853
|
confirmed: values.confirm || undefined,
|
|
2837
2854
|
overLimit: values["over-limit"] || undefined,
|
|
2838
2855
|
},
|
|
@@ -2849,6 +2866,13 @@ export async function run(argv, io = {}) {
|
|
|
2849
2866
|
? `A subtask of ${r.updated.subtaskOf}.`
|
|
2850
2867
|
: "A task of its own now."
|
|
2851
2868
|
: null,
|
|
2869
|
+
values.milestone !== undefined
|
|
2870
|
+
? r.updated.milestone
|
|
2871
|
+
? `Toward the milestone ${r.updated.milestone}.`
|
|
2872
|
+
: "Not tied to a milestone now."
|
|
2873
|
+
: null,
|
|
2874
|
+
r.milestoneReached ? `That was the last of its tasks: ${r.milestoneReached} is reached.` : null,
|
|
2875
|
+
r.milestoneReopened ? `${r.milestoneReopened} is open again: one of its tasks is open.` : null,
|
|
2852
2876
|
values.also !== undefined
|
|
2853
2877
|
? r.updated.alsoOn?.length
|
|
2854
2878
|
? `Also on it: ${r.updated.alsoOn.join(", ")}.`
|