@harborppm/cli 0.60.0 → 0.62.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 +10 -5
- package/dist/app.mjs +864 -309
- package/lib/cli.mjs +166 -22
- package/lib/format.mjs +62 -3
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -3278,31 +3278,31 @@ function barnaclesHeadline(b) {
|
|
|
3278
3278
|
if (b.added === 0) return null;
|
|
3279
3279
|
const when = b.from === "baseline" ? "since the baseline" : "since its first week";
|
|
3280
3280
|
const pct = b.share != null ? `, ${Math.round(b.share * 100)}% more than planned` : "";
|
|
3281
|
-
return `+${plural$
|
|
3281
|
+
return `+${plural$6(b.added, "task")} ${when}${pct}`;
|
|
3282
3282
|
}
|
|
3283
3283
|
/** What the added work costs in days, and how much of the slip it explains. */
|
|
3284
3284
|
function barnaclesDaysLine(b) {
|
|
3285
3285
|
if (b.added === 0 || b.days == null || b.perWeek == null) return null;
|
|
3286
|
-
const work = `At the crew's pace (about ${b.perWeek} a week) that's about ${plural$
|
|
3286
|
+
const work = `At the crew's pace (about ${b.perWeek} a week) that's about ${plural$6(b.days, "day")} of work`;
|
|
3287
3287
|
if (b.slip == null || b.slip <= 0) return `${work}.`;
|
|
3288
|
-
if (b.days >= b.slip) return `${work}: enough to explain all ${plural$
|
|
3289
|
-
return `${work}: about ${b.days} of the ${plural$
|
|
3288
|
+
if (b.days >= b.slip) return `${work}: enough to explain all ${plural$6(b.slip, "day")} of the slip.`;
|
|
3289
|
+
return `${work}: about ${b.days} of the ${plural$6(b.slip, "day")} it has slipped.`;
|
|
3290
3290
|
}
|
|
3291
3291
|
/** One line for the brief or a list: "+14 tasks (38%) since the baseline, 3 this week, about 65 days of work". */
|
|
3292
3292
|
function barnaclesShort(b) {
|
|
3293
3293
|
const pct = b.share != null ? ` (${Math.round(b.share * 100)}%)` : "";
|
|
3294
3294
|
const when = b.from === "baseline" ? "since the baseline" : "since its first week";
|
|
3295
3295
|
const week = b.thisWeek ? `, ${b.thisWeek} this week` : "";
|
|
3296
|
-
const days = b.days != null ? `, about ${plural$
|
|
3296
|
+
const days = b.days != null ? `, about ${plural$6(b.days, "day")} of work` : "";
|
|
3297
3297
|
const top = b.adders.find((a) => a.personId);
|
|
3298
3298
|
const who = top && top.added * 2 > b.added ? `, mostly from ${top.name}` : "";
|
|
3299
|
-
return `+${plural$
|
|
3299
|
+
return `+${plural$6(b.added, "task")}${pct} ${when}${week}${days}${who}`;
|
|
3300
3300
|
}
|
|
3301
3301
|
/** Whether the brief names it: measured from a baseline, enough added, and enough of the plan. */
|
|
3302
3302
|
function briefWorthy(b) {
|
|
3303
3303
|
return b.from === "baseline" && b.added >= 3 && (b.share == null || b.share >= .15);
|
|
3304
3304
|
}
|
|
3305
|
-
var SETTLE_DAYS, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$
|
|
3305
|
+
var SETTLE_DAYS, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$6;
|
|
3306
3306
|
var init_barnacles = __esmMin((() => {
|
|
3307
3307
|
init_date_fns();
|
|
3308
3308
|
init_format();
|
|
@@ -3312,7 +3312,253 @@ var init_barnacles = __esmMin((() => {
|
|
|
3312
3312
|
BRIEF_MIN_SHARE = .15;
|
|
3313
3313
|
noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
3314
3314
|
isoDay$3 = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
3315
|
-
plural$
|
|
3315
|
+
plural$6 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3316
|
+
}));
|
|
3317
|
+
|
|
3318
|
+
//#endregion
|
|
3319
|
+
//#region src/lib/task-keys.ts
|
|
3320
|
+
/** "Ridge Runner" → "RR", "Website" → "WEB", "Q4 launch plan" → "QLP". */
|
|
3321
|
+
function baseKey(name) {
|
|
3322
|
+
const words = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9 ]+/g, " ").split(/\s+/).filter((w) => /[A-Za-z]/.test(w));
|
|
3323
|
+
if (words.length === 0) return "P";
|
|
3324
|
+
return (words.length === 1 ? words[0].replace(/[^A-Za-z]/g, "").slice(0, 3) : words.map((w) => w.replace(/[^A-Za-z]/g, "")[0] ?? "").join("").slice(0, MAX_KEY)).toUpperCase() || "P";
|
|
3325
|
+
}
|
|
3326
|
+
/** A key for `name` that none of `taken` has: "RR", then "RR2", "RR3"… */
|
|
3327
|
+
function projectKeyFor(name, taken) {
|
|
3328
|
+
const base = baseKey(name);
|
|
3329
|
+
if (!taken.has(base)) return base;
|
|
3330
|
+
for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
|
|
3331
|
+
}
|
|
3332
|
+
/** Whether `a` has the better claim to the number both hold. */
|
|
3333
|
+
function first$2(a, b, stored) {
|
|
3334
|
+
const ka = kept(a, stored);
|
|
3335
|
+
if (ka !== kept(b, stored)) return ka;
|
|
3336
|
+
return made(a, b) <= 0;
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3339
|
+
* Every project keyed, every task numbered, no number given twice in a
|
|
3340
|
+
* project. When two tasks hold one number, a task already stored with it
|
|
3341
|
+
* (`stored`: the document before this write) keeps it, since people may have
|
|
3342
|
+
* used its key already; otherwise the task made first does. Returns the same
|
|
3343
|
+
* object when nothing needed doing.
|
|
3344
|
+
*/
|
|
3345
|
+
function settleKeys(data, stored) {
|
|
3346
|
+
let projects = data.projects;
|
|
3347
|
+
const taken = /* @__PURE__ */ new Set();
|
|
3348
|
+
const keeps = projects.map((p) => {
|
|
3349
|
+
const ok = !!p.key && KEY.test(p.key) && !taken.has(p.key);
|
|
3350
|
+
if (ok) taken.add(p.key);
|
|
3351
|
+
return ok;
|
|
3352
|
+
});
|
|
3353
|
+
if (keeps.includes(false)) projects = projects.map((p, i) => {
|
|
3354
|
+
if (keeps[i]) return p;
|
|
3355
|
+
const key = projectKeyFor(p.name, taken);
|
|
3356
|
+
taken.add(key);
|
|
3357
|
+
return {
|
|
3358
|
+
...p,
|
|
3359
|
+
key
|
|
3360
|
+
};
|
|
3361
|
+
});
|
|
3362
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
3363
|
+
for (const t of data.tasks) {
|
|
3364
|
+
const list = byProject.get(t.projectId);
|
|
3365
|
+
if (list) list.push(t);
|
|
3366
|
+
else byProject.set(t.projectId, [t]);
|
|
3367
|
+
}
|
|
3368
|
+
const renumber = /* @__PURE__ */ new Map();
|
|
3369
|
+
for (const list of byProject.values()) {
|
|
3370
|
+
let max = 0;
|
|
3371
|
+
const holder = /* @__PURE__ */ new Map();
|
|
3372
|
+
const loose = [];
|
|
3373
|
+
for (const t of list) {
|
|
3374
|
+
const n = t.number;
|
|
3375
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
3376
|
+
loose.push(t);
|
|
3377
|
+
continue;
|
|
3378
|
+
}
|
|
3379
|
+
const other = holder.get(n);
|
|
3380
|
+
if (other) {
|
|
3381
|
+
const [keep, move] = first$2(other, t, stored) ? [other, t] : [t, other];
|
|
3382
|
+
holder.set(n, keep);
|
|
3383
|
+
loose.push(move);
|
|
3384
|
+
} else holder.set(n, t);
|
|
3385
|
+
if (n > max) max = n;
|
|
3386
|
+
}
|
|
3387
|
+
if (loose.length === 0) continue;
|
|
3388
|
+
loose.sort(made);
|
|
3389
|
+
for (const t of loose) renumber.set(t.id, ++max);
|
|
3390
|
+
}
|
|
3391
|
+
if (projects === data.projects && renumber.size === 0) return data;
|
|
3392
|
+
const tasks = renumber.size === 0 ? data.tasks : data.tasks.map((t) => renumber.has(t.id) ? {
|
|
3393
|
+
...t,
|
|
3394
|
+
number: renumber.get(t.id)
|
|
3395
|
+
} : t);
|
|
3396
|
+
return {
|
|
3397
|
+
...data,
|
|
3398
|
+
projects,
|
|
3399
|
+
tasks
|
|
3400
|
+
};
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* The browser's half: number the tasks this change adds, and the ones it
|
|
3404
|
+
* moves to another project, after the highest number in their project. Old
|
|
3405
|
+
* tasks without a number are left for the server. Same object when nothing
|
|
3406
|
+
* was added or moved.
|
|
3407
|
+
*/
|
|
3408
|
+
function numberNewTasks(before, after) {
|
|
3409
|
+
if (after.tasks === before.tasks && after.projects === before.projects) return after;
|
|
3410
|
+
let projects = after.projects;
|
|
3411
|
+
if (projects !== before.projects) {
|
|
3412
|
+
const known = new Set(before.projects.map((p) => p.id));
|
|
3413
|
+
if (projects.some((p) => !known.has(p.id) && !p.key)) {
|
|
3414
|
+
const taken = new Set(projects.flatMap((p) => p.key ? [p.key] : []));
|
|
3415
|
+
projects = projects.map((p) => {
|
|
3416
|
+
if (known.has(p.id) || p.key) return p;
|
|
3417
|
+
const key = projectKeyFor(p.name, taken);
|
|
3418
|
+
taken.add(key);
|
|
3419
|
+
return {
|
|
3420
|
+
...p,
|
|
3421
|
+
key
|
|
3422
|
+
};
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
if (after.tasks === before.tasks) return projects === after.projects ? after : {
|
|
3427
|
+
...after,
|
|
3428
|
+
projects
|
|
3429
|
+
};
|
|
3430
|
+
const was = byId(before.tasks);
|
|
3431
|
+
const holders = /* @__PURE__ */ new Map();
|
|
3432
|
+
for (const t of after.tasks) {
|
|
3433
|
+
if (!Number.isInteger(t.number)) continue;
|
|
3434
|
+
const k = `${t.projectId}:${t.number}`;
|
|
3435
|
+
holders.set(k, (holders.get(k) ?? 0) + 1);
|
|
3436
|
+
}
|
|
3437
|
+
const keepsNumber = (t) => Number.isInteger(t.number) && holders.get(`${t.projectId}:${t.number}`) === 1;
|
|
3438
|
+
const fresh = (t) => {
|
|
3439
|
+
const old = was.get(t.id);
|
|
3440
|
+
if (!old) return !keepsNumber(t);
|
|
3441
|
+
return old.projectId !== t.projectId && old.number === t.number;
|
|
3442
|
+
};
|
|
3443
|
+
if (!after.tasks.some(fresh)) return projects === after.projects ? after : {
|
|
3444
|
+
...after,
|
|
3445
|
+
projects
|
|
3446
|
+
};
|
|
3447
|
+
const max = /* @__PURE__ */ new Map();
|
|
3448
|
+
for (const t of after.tasks) {
|
|
3449
|
+
if (fresh(t) || !Number.isInteger(t.number)) continue;
|
|
3450
|
+
if (t.number > (max.get(t.projectId) ?? 0)) max.set(t.projectId, t.number);
|
|
3451
|
+
}
|
|
3452
|
+
const tasks = after.tasks.map((t) => {
|
|
3453
|
+
if (!fresh(t)) return t;
|
|
3454
|
+
const n = (max.get(t.projectId) ?? 0) + 1;
|
|
3455
|
+
max.set(t.projectId, n);
|
|
3456
|
+
return {
|
|
3457
|
+
...t,
|
|
3458
|
+
number: n
|
|
3459
|
+
};
|
|
3460
|
+
});
|
|
3461
|
+
return {
|
|
3462
|
+
...after,
|
|
3463
|
+
projects,
|
|
3464
|
+
tasks
|
|
3465
|
+
};
|
|
3466
|
+
}
|
|
3467
|
+
/** "RR-42", or null for a task without a number yet. */
|
|
3468
|
+
function taskKey(task, project) {
|
|
3469
|
+
return project?.key && task.number ? `${project.key}-${task.number}` : null;
|
|
3470
|
+
}
|
|
3471
|
+
/** The task a key names ("rr-42" works too), or null. */
|
|
3472
|
+
function findByKey(data, text) {
|
|
3473
|
+
const m = KEY_TEXT.exec(text.trim());
|
|
3474
|
+
if (!m) return null;
|
|
3475
|
+
const project = data.projects.find((p) => p.key === m[1].toUpperCase());
|
|
3476
|
+
if (!project) return null;
|
|
3477
|
+
const n = Number(m[2]);
|
|
3478
|
+
return data.tasks.find((t) => t.projectId === project.id && t.number === n) ?? null;
|
|
3479
|
+
}
|
|
3480
|
+
var MAX_KEY, KEY, made, kept, KEY_TEXT;
|
|
3481
|
+
var init_task_keys = __esmMin((() => {
|
|
3482
|
+
init_indexes();
|
|
3483
|
+
MAX_KEY = 5;
|
|
3484
|
+
KEY = /^[A-Z][A-Z0-9]{0,9}$/;
|
|
3485
|
+
made = (a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
3486
|
+
kept = (t, stored) => {
|
|
3487
|
+
const was = stored?.get(t.id);
|
|
3488
|
+
return !!was && was.number === t.number && was.projectId === t.projectId;
|
|
3489
|
+
};
|
|
3490
|
+
KEY_TEXT = /^([A-Za-z][A-Za-z0-9]{0,9})-(\d{1,7})$/;
|
|
3491
|
+
}));
|
|
3492
|
+
|
|
3493
|
+
//#endregion
|
|
3494
|
+
//#region src/lib/thanks.ts
|
|
3495
|
+
/** What a thanks is for, read from the harbor; null once that's gone. */
|
|
3496
|
+
function thanksTarget(doc, on) {
|
|
3497
|
+
if (on.kind === "task") {
|
|
3498
|
+
const t = doc.tasks.find((x) => x.id === on.id);
|
|
3499
|
+
if (!t) return null;
|
|
3500
|
+
const project = byId(doc.projects).get(t.projectId);
|
|
3501
|
+
const key = taskKey(t, project);
|
|
3502
|
+
return {
|
|
3503
|
+
title: `${key ? `${key} ` : ""}${t.title}`,
|
|
3504
|
+
projectId: t.projectId,
|
|
3505
|
+
programId: project?.programId ?? null,
|
|
3506
|
+
suggested: t.assigneeId
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
3509
|
+
if (on.kind === "milestone") {
|
|
3510
|
+
const m = doc.milestones.find((x) => x.id === on.id);
|
|
3511
|
+
if (!m) return null;
|
|
3512
|
+
return {
|
|
3513
|
+
title: m.name,
|
|
3514
|
+
projectId: "",
|
|
3515
|
+
programId: m.programId,
|
|
3516
|
+
suggested: m.approverId ?? null
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
const d = doc.raidItems.find((x) => x.id === on.id && x.kind === "decision");
|
|
3520
|
+
if (!d) return null;
|
|
3521
|
+
const project = d.projectId ? byId(doc.projects).get(d.projectId) : void 0;
|
|
3522
|
+
return {
|
|
3523
|
+
title: d.title,
|
|
3524
|
+
projectId: d.projectId ?? "",
|
|
3525
|
+
programId: project?.programId ?? d.programId ?? null,
|
|
3526
|
+
suggested: d.decidedById ?? d.ownerId ?? null
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3529
|
+
/** Why a thanks can't be given, or null when it can. */
|
|
3530
|
+
function thanksProblem(body, toId, fromId) {
|
|
3531
|
+
if (!body.trim()) return "Say what you're thanking them for.";
|
|
3532
|
+
if (!toId) return "Say who it's for.";
|
|
3533
|
+
if (toId === fromId) return "Thanks go to someone else.";
|
|
3534
|
+
return null;
|
|
3535
|
+
}
|
|
3536
|
+
/** Thanks given since `since`, newest first, with names and what each was for; `keep` narrows them. */
|
|
3537
|
+
function thanksSince(doc, since, keep = () => true) {
|
|
3538
|
+
const out = [];
|
|
3539
|
+
for (const r of doc.reflections ?? []) {
|
|
3540
|
+
if (!isThanks(r) || r.createdAt < since) continue;
|
|
3541
|
+
const target = thanksTarget(doc, r.thanksFor);
|
|
3542
|
+
if (!target || !keep(target)) continue;
|
|
3543
|
+
out.push({
|
|
3544
|
+
thanks: r,
|
|
3545
|
+
from: first$1(doc, r.authorId),
|
|
3546
|
+
to: first$1(doc, r.toId),
|
|
3547
|
+
for: target.title
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
return out.sort((a, b) => b.thanks.createdAt.localeCompare(a.thanks.createdAt));
|
|
3551
|
+
}
|
|
3552
|
+
/** "Aya to Sam for RR-4 Draft the copy: “Saved the launch.”" */
|
|
3553
|
+
function thanksText(line) {
|
|
3554
|
+
return `${line.from} to ${line.to} for ${line.for}: “${line.thanks.body.trim()}”`;
|
|
3555
|
+
}
|
|
3556
|
+
var isThanks, first$1;
|
|
3557
|
+
var init_thanks = __esmMin((() => {
|
|
3558
|
+
init_indexes();
|
|
3559
|
+
init_task_keys();
|
|
3560
|
+
isThanks = (r) => r.kind === "thanks" && !!r.thanksFor && !!r.toId;
|
|
3561
|
+
first$1 = (doc, id) => doc.people.find((p) => p.id === id)?.name.split(" ")[0] ?? "Someone";
|
|
3316
3562
|
}));
|
|
3317
3563
|
|
|
3318
3564
|
//#endregion
|
|
@@ -3353,8 +3599,8 @@ var init_charter_lines = __esmMin((() => {
|
|
|
3353
3599
|
|
|
3354
3600
|
//#endregion
|
|
3355
3601
|
//#region src/lib/charter.ts
|
|
3356
|
-
init_indexes();
|
|
3357
3602
|
init_barnacles();
|
|
3603
|
+
init_thanks();
|
|
3358
3604
|
init_date_fns();
|
|
3359
3605
|
init_date_text();
|
|
3360
3606
|
init_premortem();
|
|
@@ -3521,6 +3767,7 @@ function voyageLog(data, projectId) {
|
|
|
3521
3767
|
closed
|
|
3522
3768
|
})).filter((c) => c.person).sort((a, b) => b.closed - a.closed || a.person.name.localeCompare(b.person.name));
|
|
3523
3769
|
const lines = (data.reflections ?? []).filter((r) => r.projectId === projectId && r.kind === "voyage").sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
3770
|
+
const thanks = (data.reflections ?? []).filter((r) => r.projectId === projectId && isThanks(r)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
3524
3771
|
return {
|
|
3525
3772
|
project,
|
|
3526
3773
|
done: project.health === "done",
|
|
@@ -3540,10 +3787,11 @@ function voyageLog(data, projectId) {
|
|
|
3540
3787
|
carriers,
|
|
3541
3788
|
budget: project.budget,
|
|
3542
3789
|
spent: project.spent,
|
|
3543
|
-
lines
|
|
3790
|
+
lines,
|
|
3791
|
+
thanks
|
|
3544
3792
|
};
|
|
3545
3793
|
}
|
|
3546
|
-
const plural$
|
|
3794
|
+
const plural$5 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3547
3795
|
const md$2 = (iso) => format(day$6(iso), "MMM d");
|
|
3548
3796
|
/** The three numbers at the top: how late, how much work, how many risks came true. */
|
|
3549
3797
|
function voyageStats(log) {
|
|
@@ -3558,10 +3806,10 @@ function voyageStats(log) {
|
|
|
3558
3806
|
value: "On time",
|
|
3559
3807
|
label: "on the planned day"
|
|
3560
3808
|
} : days > 0 ? {
|
|
3561
|
-
value: `+${plural$
|
|
3809
|
+
value: `+${plural$5(days, "day")}`,
|
|
3562
3810
|
label: `past the ${plan}`
|
|
3563
3811
|
} : {
|
|
3564
|
-
value: plural$
|
|
3812
|
+
value: plural$5(-days, "day"),
|
|
3565
3813
|
label: `ahead of the ${plan}`
|
|
3566
3814
|
},
|
|
3567
3815
|
{
|
|
@@ -3587,7 +3835,7 @@ function voyageStory(log) {
|
|
|
3587
3835
|
out.push(`Barnacles: ${grew}${by}.${days ? ` ${days}` : ""}`);
|
|
3588
3836
|
}
|
|
3589
3837
|
for (const s of log.slips) {
|
|
3590
|
-
const late = `“${s.task.title}” finished ${plural$
|
|
3838
|
+
const late = `“${s.task.title}” finished ${plural$5(s.daysLate, "day")} late`;
|
|
3591
3839
|
out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md$2(s.waitedOn.completedAt)}.` : `${late}.`);
|
|
3592
3840
|
}
|
|
3593
3841
|
for (const r of log.risks.filter((x) => x.happened)) out.push(`The risk “${r.risk.title}” came true${r.guessed ? " (an issue names it)" : ""}.`);
|
|
@@ -3595,11 +3843,11 @@ function voyageStory(log) {
|
|
|
3595
3843
|
if (against) out.push(against);
|
|
3596
3844
|
if (log.decisions.length) {
|
|
3597
3845
|
const changed = log.decisions.filter((d) => d.status === "superseded").length;
|
|
3598
|
-
out.push(`${plural$
|
|
3846
|
+
out.push(`${plural$5(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
|
|
3599
3847
|
}
|
|
3600
3848
|
const top = log.carriers[0];
|
|
3601
|
-
if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural$
|
|
3602
|
-
else if (top) out.push(`${top.person.name} closed ${plural$
|
|
3849
|
+
if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural$5(top.closed, "task")}, the most on the crew.`);
|
|
3850
|
+
else if (top) out.push(`${top.person.name} closed ${plural$5(top.closed, "task")}.`);
|
|
3603
3851
|
if (log.budget > 0) {
|
|
3604
3852
|
const pct = Math.round(log.spent / log.budget * 100);
|
|
3605
3853
|
out.push(`Spent ${pct}% of the budget.`);
|
|
@@ -3612,6 +3860,7 @@ function voyageStory(log) {
|
|
|
3612
3860
|
init_date_fns();
|
|
3613
3861
|
init_date_text();
|
|
3614
3862
|
init_format();
|
|
3863
|
+
init_indexes();
|
|
3615
3864
|
init_premortem();
|
|
3616
3865
|
const DAY = (iso) => parseIso(iso.slice(0, 10));
|
|
3617
3866
|
const span$1 = (a, b) => differenceInCalendarDays(DAY(b), DAY(a));
|
|
@@ -4582,6 +4831,7 @@ function cascadeDelete(data, kind, id) {
|
|
|
4582
4831
|
const projectIds = new Set(kind === "project" ? [id] : data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id));
|
|
4583
4832
|
const taskIds = new Set(data.tasks.filter((t) => projectIds.has(t.projectId)).map((t) => t.id));
|
|
4584
4833
|
const milestones = data.milestones.filter((m) => !programIds.has(m.programId)).map((m) => withoutBlockers(m, projectIds));
|
|
4834
|
+
const goneMilestones = new Set(data.milestones.filter((m) => programIds.has(m.programId)).map((m) => m.id));
|
|
4585
4835
|
const constraints = (data.constraints ?? []).filter((c) => !programIds.has(c.programId));
|
|
4586
4836
|
const raidItems = data.raidItems.filter((r) => !(r.portfolioId && portfolioIds.has(r.portfolioId)) && !(r.programId && programIds.has(r.programId)) && !(r.projectId && projectIds.has(r.projectId)));
|
|
4587
4837
|
const boardConfigs = Object.fromEntries(Object.entries(data.boardConfigs ?? {}).filter(([k]) => !projectIds.has(k)));
|
|
@@ -4602,7 +4852,7 @@ function cascadeDelete(data, kind, id) {
|
|
|
4602
4852
|
taskIds: r.taskIds.filter((t) => !taskIds.has(t))
|
|
4603
4853
|
})),
|
|
4604
4854
|
spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId)),
|
|
4605
|
-
reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId))
|
|
4855
|
+
reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId) && !(r.thanksFor?.kind === "milestone" && goneMilestones.has(r.thanksFor.id)))
|
|
4606
4856
|
},
|
|
4607
4857
|
counts: {
|
|
4608
4858
|
programs: programIds.size,
|
|
@@ -5007,258 +5257,83 @@ function at$1(iso, w) {
|
|
|
5007
5257
|
function span(start, end, w) {
|
|
5008
5258
|
if (!start || !end || end < w.start || start >= w.end) return null;
|
|
5009
5259
|
const from = start < w.start ? w.start : start;
|
|
5010
|
-
const until = end >= w.end ? w.end : isoDay$2(new Date(day$2(end).getTime() + 864e5));
|
|
5011
|
-
const left = differenceInCalendarDays(day$2(from), day$2(w.start)) / w.days;
|
|
5012
|
-
const right = differenceInCalendarDays(day$2(until), day$2(w.start)) / w.days;
|
|
5013
|
-
return {
|
|
5014
|
-
left,
|
|
5015
|
-
width: Math.max(right - left, 1 / w.days),
|
|
5016
|
-
clippedStart: start < w.start,
|
|
5017
|
-
clippedEnd: end >= w.end
|
|
5018
|
-
};
|
|
5019
|
-
}
|
|
5020
|
-
const ATTENTION = ["at-risk", "off-track"];
|
|
5021
|
-
function matches$1(row, f) {
|
|
5022
|
-
if (f.health === "attention" && !ATTENTION.includes(row.health)) return false;
|
|
5023
|
-
if (f.ownerId && row.ownerId !== f.ownerId) return false;
|
|
5024
|
-
return true;
|
|
5025
|
-
}
|
|
5026
|
-
function milestoneOf(m, today) {
|
|
5027
|
-
return {
|
|
5028
|
-
id: m.id,
|
|
5029
|
-
name: m.name,
|
|
5030
|
-
due: m.dueDate,
|
|
5031
|
-
done: m.done,
|
|
5032
|
-
gate: Boolean(m.approverId),
|
|
5033
|
-
approved: m.approvalStatus === "approved",
|
|
5034
|
-
late: !m.done && m.dueDate < today
|
|
5035
|
-
};
|
|
5036
|
-
}
|
|
5037
|
-
/**
|
|
5038
|
-
* Programs by start date, each followed by its projects by start date unless
|
|
5039
|
-
* collapsed. A filter keeps a program when it or any of its projects match,
|
|
5040
|
-
* and keeps the projects that match.
|
|
5041
|
-
*/
|
|
5042
|
-
function roadmapRows(data, portfolioId, filter, collapsed, today) {
|
|
5043
|
-
const programs = data.programs.filter((p) => p.portfolioId === portfolioId).sort((a, b) => a.startDate.localeCompare(b.startDate) || byName(a.name, b.name));
|
|
5044
|
-
const programIds = new Set(programs.map((p) => p.id));
|
|
5045
|
-
const projectIds = new Set(data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id));
|
|
5046
|
-
const out = [];
|
|
5047
|
-
for (const program of programs) {
|
|
5048
|
-
const projects = data.projects.filter((p) => p.programId === program.id).sort((a, b) => a.startDate.localeCompare(b.startDate) || byName(a.name, b.name));
|
|
5049
|
-
const shownProjects = projects.filter((p) => matches$1(p, filter));
|
|
5050
|
-
if (!matches$1(program, filter) && shownProjects.length === 0) continue;
|
|
5051
|
-
out.push({
|
|
5052
|
-
kind: "program",
|
|
5053
|
-
id: program.id,
|
|
5054
|
-
name: program.name,
|
|
5055
|
-
programId: program.id,
|
|
5056
|
-
start: program.startDate,
|
|
5057
|
-
end: program.endDate,
|
|
5058
|
-
health: program.health,
|
|
5059
|
-
ownerId: program.ownerId,
|
|
5060
|
-
baselineStart: null,
|
|
5061
|
-
baselineEnd: null,
|
|
5062
|
-
slip: null,
|
|
5063
|
-
waitsOn: blockerIds(program).filter((id) => programIds.has(id)),
|
|
5064
|
-
milestones: data.milestones.filter((m) => m.programId === program.id).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).map((m) => milestoneOf(m, today)),
|
|
5065
|
-
projectCount: projects.length
|
|
5066
|
-
});
|
|
5067
|
-
if (collapsed.has(program.id)) continue;
|
|
5068
|
-
for (const p of shownProjects) out.push({
|
|
5069
|
-
kind: "project",
|
|
5070
|
-
id: p.id,
|
|
5071
|
-
name: p.name,
|
|
5072
|
-
programId: program.id,
|
|
5073
|
-
start: p.startDate,
|
|
5074
|
-
end: p.endDate,
|
|
5075
|
-
health: p.health,
|
|
5076
|
-
ownerId: p.ownerId,
|
|
5077
|
-
baselineStart: p.baselineStart ?? null,
|
|
5078
|
-
baselineEnd: p.baselineEnd ?? null,
|
|
5079
|
-
slip: slipDays(p),
|
|
5080
|
-
waitsOn: blockerIds(p).filter((id) => projectIds.has(id)),
|
|
5081
|
-
milestones: [],
|
|
5082
|
-
projectCount: 0
|
|
5083
|
-
});
|
|
5084
|
-
}
|
|
5085
|
-
return out;
|
|
5086
|
-
}
|
|
5087
|
-
|
|
5088
|
-
//#endregion
|
|
5089
|
-
//#region src/lib/task-keys.ts
|
|
5090
|
-
/** "Ridge Runner" → "RR", "Website" → "WEB", "Q4 launch plan" → "QLP". */
|
|
5091
|
-
function baseKey(name) {
|
|
5092
|
-
const words = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9 ]+/g, " ").split(/\s+/).filter((w) => /[A-Za-z]/.test(w));
|
|
5093
|
-
if (words.length === 0) return "P";
|
|
5094
|
-
return (words.length === 1 ? words[0].replace(/[^A-Za-z]/g, "").slice(0, 3) : words.map((w) => w.replace(/[^A-Za-z]/g, "")[0] ?? "").join("").slice(0, MAX_KEY)).toUpperCase() || "P";
|
|
5095
|
-
}
|
|
5096
|
-
/** A key for `name` that none of `taken` has: "RR", then "RR2", "RR3"… */
|
|
5097
|
-
function projectKeyFor(name, taken) {
|
|
5098
|
-
const base = baseKey(name);
|
|
5099
|
-
if (!taken.has(base)) return base;
|
|
5100
|
-
for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
|
|
5101
|
-
}
|
|
5102
|
-
/** Whether `a` has the better claim to the number both hold. */
|
|
5103
|
-
function first$1(a, b, stored) {
|
|
5104
|
-
const ka = kept(a, stored);
|
|
5105
|
-
if (ka !== kept(b, stored)) return ka;
|
|
5106
|
-
return made(a, b) <= 0;
|
|
5107
|
-
}
|
|
5108
|
-
/**
|
|
5109
|
-
* Every project keyed, every task numbered, no number given twice in a
|
|
5110
|
-
* project. When two tasks hold one number, a task already stored with it
|
|
5111
|
-
* (`stored`: the document before this write) keeps it, since people may have
|
|
5112
|
-
* used its key already; otherwise the task made first does. Returns the same
|
|
5113
|
-
* object when nothing needed doing.
|
|
5114
|
-
*/
|
|
5115
|
-
function settleKeys(data, stored) {
|
|
5116
|
-
let projects = data.projects;
|
|
5117
|
-
const taken = /* @__PURE__ */ new Set();
|
|
5118
|
-
const keeps = projects.map((p) => {
|
|
5119
|
-
const ok = !!p.key && KEY.test(p.key) && !taken.has(p.key);
|
|
5120
|
-
if (ok) taken.add(p.key);
|
|
5121
|
-
return ok;
|
|
5122
|
-
});
|
|
5123
|
-
if (keeps.includes(false)) projects = projects.map((p, i) => {
|
|
5124
|
-
if (keeps[i]) return p;
|
|
5125
|
-
const key = projectKeyFor(p.name, taken);
|
|
5126
|
-
taken.add(key);
|
|
5127
|
-
return {
|
|
5128
|
-
...p,
|
|
5129
|
-
key
|
|
5130
|
-
};
|
|
5131
|
-
});
|
|
5132
|
-
const byProject = /* @__PURE__ */ new Map();
|
|
5133
|
-
for (const t of data.tasks) {
|
|
5134
|
-
const list = byProject.get(t.projectId);
|
|
5135
|
-
if (list) list.push(t);
|
|
5136
|
-
else byProject.set(t.projectId, [t]);
|
|
5137
|
-
}
|
|
5138
|
-
const renumber = /* @__PURE__ */ new Map();
|
|
5139
|
-
for (const list of byProject.values()) {
|
|
5140
|
-
let max = 0;
|
|
5141
|
-
const holder = /* @__PURE__ */ new Map();
|
|
5142
|
-
const loose = [];
|
|
5143
|
-
for (const t of list) {
|
|
5144
|
-
const n = t.number;
|
|
5145
|
-
if (!Number.isInteger(n) || n < 1) {
|
|
5146
|
-
loose.push(t);
|
|
5147
|
-
continue;
|
|
5148
|
-
}
|
|
5149
|
-
const other = holder.get(n);
|
|
5150
|
-
if (other) {
|
|
5151
|
-
const [keep, move] = first$1(other, t, stored) ? [other, t] : [t, other];
|
|
5152
|
-
holder.set(n, keep);
|
|
5153
|
-
loose.push(move);
|
|
5154
|
-
} else holder.set(n, t);
|
|
5155
|
-
if (n > max) max = n;
|
|
5156
|
-
}
|
|
5157
|
-
if (loose.length === 0) continue;
|
|
5158
|
-
loose.sort(made);
|
|
5159
|
-
for (const t of loose) renumber.set(t.id, ++max);
|
|
5160
|
-
}
|
|
5161
|
-
if (projects === data.projects && renumber.size === 0) return data;
|
|
5162
|
-
const tasks = renumber.size === 0 ? data.tasks : data.tasks.map((t) => renumber.has(t.id) ? {
|
|
5163
|
-
...t,
|
|
5164
|
-
number: renumber.get(t.id)
|
|
5165
|
-
} : t);
|
|
5166
|
-
return {
|
|
5167
|
-
...data,
|
|
5168
|
-
projects,
|
|
5169
|
-
tasks
|
|
5170
|
-
};
|
|
5171
|
-
}
|
|
5172
|
-
/**
|
|
5173
|
-
* The browser's half: number the tasks this change adds, and the ones it
|
|
5174
|
-
* moves to another project, after the highest number in their project. Old
|
|
5175
|
-
* tasks without a number are left for the server. Same object when nothing
|
|
5176
|
-
* was added or moved.
|
|
5177
|
-
*/
|
|
5178
|
-
function numberNewTasks(before, after) {
|
|
5179
|
-
if (after.tasks === before.tasks && after.projects === before.projects) return after;
|
|
5180
|
-
let projects = after.projects;
|
|
5181
|
-
if (projects !== before.projects) {
|
|
5182
|
-
const known = new Set(before.projects.map((p) => p.id));
|
|
5183
|
-
if (projects.some((p) => !known.has(p.id) && !p.key)) {
|
|
5184
|
-
const taken = new Set(projects.flatMap((p) => p.key ? [p.key] : []));
|
|
5185
|
-
projects = projects.map((p) => {
|
|
5186
|
-
if (known.has(p.id) || p.key) return p;
|
|
5187
|
-
const key = projectKeyFor(p.name, taken);
|
|
5188
|
-
taken.add(key);
|
|
5189
|
-
return {
|
|
5190
|
-
...p,
|
|
5191
|
-
key
|
|
5192
|
-
};
|
|
5193
|
-
});
|
|
5194
|
-
}
|
|
5195
|
-
}
|
|
5196
|
-
if (after.tasks === before.tasks) return projects === after.projects ? after : {
|
|
5197
|
-
...after,
|
|
5198
|
-
projects
|
|
5199
|
-
};
|
|
5200
|
-
const was = byId(before.tasks);
|
|
5201
|
-
const holders = /* @__PURE__ */ new Map();
|
|
5202
|
-
for (const t of after.tasks) {
|
|
5203
|
-
if (!Number.isInteger(t.number)) continue;
|
|
5204
|
-
const k = `${t.projectId}:${t.number}`;
|
|
5205
|
-
holders.set(k, (holders.get(k) ?? 0) + 1);
|
|
5206
|
-
}
|
|
5207
|
-
const keepsNumber = (t) => Number.isInteger(t.number) && holders.get(`${t.projectId}:${t.number}`) === 1;
|
|
5208
|
-
const fresh = (t) => {
|
|
5209
|
-
const old = was.get(t.id);
|
|
5210
|
-
if (!old) return !keepsNumber(t);
|
|
5211
|
-
return old.projectId !== t.projectId && old.number === t.number;
|
|
5212
|
-
};
|
|
5213
|
-
if (!after.tasks.some(fresh)) return projects === after.projects ? after : {
|
|
5214
|
-
...after,
|
|
5215
|
-
projects
|
|
5216
|
-
};
|
|
5217
|
-
const max = /* @__PURE__ */ new Map();
|
|
5218
|
-
for (const t of after.tasks) {
|
|
5219
|
-
if (fresh(t) || !Number.isInteger(t.number)) continue;
|
|
5220
|
-
if (t.number > (max.get(t.projectId) ?? 0)) max.set(t.projectId, t.number);
|
|
5221
|
-
}
|
|
5222
|
-
const tasks = after.tasks.map((t) => {
|
|
5223
|
-
if (!fresh(t)) return t;
|
|
5224
|
-
const n = (max.get(t.projectId) ?? 0) + 1;
|
|
5225
|
-
max.set(t.projectId, n);
|
|
5226
|
-
return {
|
|
5227
|
-
...t,
|
|
5228
|
-
number: n
|
|
5229
|
-
};
|
|
5230
|
-
});
|
|
5260
|
+
const until = end >= w.end ? w.end : isoDay$2(new Date(day$2(end).getTime() + 864e5));
|
|
5261
|
+
const left = differenceInCalendarDays(day$2(from), day$2(w.start)) / w.days;
|
|
5262
|
+
const right = differenceInCalendarDays(day$2(until), day$2(w.start)) / w.days;
|
|
5231
5263
|
return {
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5264
|
+
left,
|
|
5265
|
+
width: Math.max(right - left, 1 / w.days),
|
|
5266
|
+
clippedStart: start < w.start,
|
|
5267
|
+
clippedEnd: end >= w.end
|
|
5235
5268
|
};
|
|
5236
5269
|
}
|
|
5237
|
-
|
|
5238
|
-
function
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
function findByKey(data, text) {
|
|
5243
|
-
const m = KEY_TEXT.exec(text.trim());
|
|
5244
|
-
if (!m) return null;
|
|
5245
|
-
const project = data.projects.find((p) => p.key === m[1].toUpperCase());
|
|
5246
|
-
if (!project) return null;
|
|
5247
|
-
const n = Number(m[2]);
|
|
5248
|
-
return data.tasks.find((t) => t.projectId === project.id && t.number === n) ?? null;
|
|
5270
|
+
const ATTENTION = ["at-risk", "off-track"];
|
|
5271
|
+
function matches$1(row, f) {
|
|
5272
|
+
if (f.health === "attention" && !ATTENTION.includes(row.health)) return false;
|
|
5273
|
+
if (f.ownerId && row.ownerId !== f.ownerId) return false;
|
|
5274
|
+
return true;
|
|
5249
5275
|
}
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5276
|
+
function milestoneOf(m, today) {
|
|
5277
|
+
return {
|
|
5278
|
+
id: m.id,
|
|
5279
|
+
name: m.name,
|
|
5280
|
+
due: m.dueDate,
|
|
5281
|
+
done: m.done,
|
|
5282
|
+
gate: Boolean(m.approverId),
|
|
5283
|
+
approved: m.approvalStatus === "approved",
|
|
5284
|
+
late: !m.done && m.dueDate < today
|
|
5259
5285
|
};
|
|
5260
|
-
|
|
5261
|
-
|
|
5286
|
+
}
|
|
5287
|
+
/**
|
|
5288
|
+
* Programs by start date, each followed by its projects by start date unless
|
|
5289
|
+
* collapsed. A filter keeps a program when it or any of its projects match,
|
|
5290
|
+
* and keeps the projects that match.
|
|
5291
|
+
*/
|
|
5292
|
+
function roadmapRows(data, portfolioId, filter, collapsed, today) {
|
|
5293
|
+
const programs = data.programs.filter((p) => p.portfolioId === portfolioId).sort((a, b) => a.startDate.localeCompare(b.startDate) || byName(a.name, b.name));
|
|
5294
|
+
const programIds = new Set(programs.map((p) => p.id));
|
|
5295
|
+
const projectIds = new Set(data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id));
|
|
5296
|
+
const out = [];
|
|
5297
|
+
for (const program of programs) {
|
|
5298
|
+
const projects = data.projects.filter((p) => p.programId === program.id).sort((a, b) => a.startDate.localeCompare(b.startDate) || byName(a.name, b.name));
|
|
5299
|
+
const shownProjects = projects.filter((p) => matches$1(p, filter));
|
|
5300
|
+
if (!matches$1(program, filter) && shownProjects.length === 0) continue;
|
|
5301
|
+
out.push({
|
|
5302
|
+
kind: "program",
|
|
5303
|
+
id: program.id,
|
|
5304
|
+
name: program.name,
|
|
5305
|
+
programId: program.id,
|
|
5306
|
+
start: program.startDate,
|
|
5307
|
+
end: program.endDate,
|
|
5308
|
+
health: program.health,
|
|
5309
|
+
ownerId: program.ownerId,
|
|
5310
|
+
baselineStart: null,
|
|
5311
|
+
baselineEnd: null,
|
|
5312
|
+
slip: null,
|
|
5313
|
+
waitsOn: blockerIds(program).filter((id) => programIds.has(id)),
|
|
5314
|
+
milestones: data.milestones.filter((m) => m.programId === program.id).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).map((m) => milestoneOf(m, today)),
|
|
5315
|
+
projectCount: projects.length
|
|
5316
|
+
});
|
|
5317
|
+
if (collapsed.has(program.id)) continue;
|
|
5318
|
+
for (const p of shownProjects) out.push({
|
|
5319
|
+
kind: "project",
|
|
5320
|
+
id: p.id,
|
|
5321
|
+
name: p.name,
|
|
5322
|
+
programId: program.id,
|
|
5323
|
+
start: p.startDate,
|
|
5324
|
+
end: p.endDate,
|
|
5325
|
+
health: p.health,
|
|
5326
|
+
ownerId: p.ownerId,
|
|
5327
|
+
baselineStart: p.baselineStart ?? null,
|
|
5328
|
+
baselineEnd: p.baselineEnd ?? null,
|
|
5329
|
+
slip: slipDays(p),
|
|
5330
|
+
waitsOn: blockerIds(p).filter((id) => projectIds.has(id)),
|
|
5331
|
+
milestones: [],
|
|
5332
|
+
projectCount: 0
|
|
5333
|
+
});
|
|
5334
|
+
}
|
|
5335
|
+
return out;
|
|
5336
|
+
}
|
|
5262
5337
|
|
|
5263
5338
|
//#endregion
|
|
5264
5339
|
//#region src/lib/search.ts
|
|
@@ -5564,7 +5639,7 @@ function projectSummary(data, project) {
|
|
|
5564
5639
|
* A due date typed in the terminal: 2026-10-14, today, tomorrow, +3 (days
|
|
5565
5640
|
* from today), or none. Null when it can't be read; "" for none.
|
|
5566
5641
|
*/
|
|
5567
|
-
function readDue(text, today) {
|
|
5642
|
+
function readDue$1(text, today) {
|
|
5568
5643
|
const t = text.trim().toLowerCase();
|
|
5569
5644
|
if (t === "" || t === "none" || t === "-") return "";
|
|
5570
5645
|
if (t === "today") return today;
|
|
@@ -6368,7 +6443,7 @@ function Editor({ label, edit, done }) {
|
|
|
6368
6443
|
hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
|
|
6369
6444
|
onCancel: done,
|
|
6370
6445
|
onSubmit: (text) => {
|
|
6371
|
-
const due = readDue(text, today());
|
|
6446
|
+
const due = readDue$1(text, today());
|
|
6372
6447
|
if (due == null) return setError(`“${text}” isn't a date Harbor can read.`);
|
|
6373
6448
|
done();
|
|
6374
6449
|
edit.onSave(due || null);
|
|
@@ -6535,7 +6610,7 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
|
|
|
6535
6610
|
const t = text.trim();
|
|
6536
6611
|
if (!t && !step.optional) return setError(`${step.label} is needed.`);
|
|
6537
6612
|
if (step.kind === "date") {
|
|
6538
|
-
const due = t ? readDue(t, today()) : "";
|
|
6613
|
+
const due = t ? readDue$1(t, today()) : "";
|
|
6539
6614
|
if (due == null) return setError(`“${text}” isn't a date Harbor can read.`);
|
|
6540
6615
|
return next(due || null);
|
|
6541
6616
|
}
|
|
@@ -7171,10 +7246,17 @@ function composeBriefBody(opts) {
|
|
|
7171
7246
|
for (const s of opts.said) lines.push(`• ${s.name}, ${format(/* @__PURE__ */ new Date(`${s.date}T12:00:00`), "EEE")}: ${s.body.trim()}`);
|
|
7172
7247
|
lines.push("");
|
|
7173
7248
|
};
|
|
7249
|
+
const thanks = () => {
|
|
7250
|
+
if (!opts.thanks?.length) return;
|
|
7251
|
+
lines.push("Fair winds");
|
|
7252
|
+
for (const t of opts.thanks) lines.push(`• ${t}`);
|
|
7253
|
+
lines.push("");
|
|
7254
|
+
};
|
|
7174
7255
|
if (quiet) {
|
|
7175
7256
|
lines.push(BRIEF_QUIET, "");
|
|
7176
7257
|
done();
|
|
7177
7258
|
said();
|
|
7259
|
+
thanks();
|
|
7178
7260
|
return lines.join("\n").trim();
|
|
7179
7261
|
}
|
|
7180
7262
|
if (opts.offTrack.length) {
|
|
@@ -7218,6 +7300,7 @@ function composeBriefBody(opts) {
|
|
|
7218
7300
|
}
|
|
7219
7301
|
done();
|
|
7220
7302
|
said();
|
|
7303
|
+
thanks();
|
|
7221
7304
|
if (opts.onTrack.length) {
|
|
7222
7305
|
lines.push("On track");
|
|
7223
7306
|
lines.push(opts.onTrack.map((p) => p.name).join(", "));
|
|
@@ -7407,6 +7490,7 @@ function briefOf(data, opts = {}) {
|
|
|
7407
7490
|
projectIds,
|
|
7408
7491
|
portfolioId
|
|
7409
7492
|
}), todayISO(now)),
|
|
7493
|
+
thanks: thanksSince(data, weekAgo, (t) => t.projectId ? projectIds.has(t.projectId) : !!t.programId && programIds.has(t.programId)),
|
|
7410
7494
|
barnacles: barnacleLinesOf(data, projects, now),
|
|
7411
7495
|
talk,
|
|
7412
7496
|
milestonesSoon,
|
|
@@ -7465,6 +7549,7 @@ function composeBriefBodyFrom(brief, data, workspace, all = false) {
|
|
|
7465
7549
|
reason: d.reason,
|
|
7466
7550
|
where: d.where
|
|
7467
7551
|
})),
|
|
7552
|
+
thanks: brief.thanks.map(thanksText),
|
|
7468
7553
|
barnacles: brief.barnacles.map((b) => ({
|
|
7469
7554
|
project: b.project.name,
|
|
7470
7555
|
line: barnaclesShort(b.barnacles)
|
|
@@ -7481,6 +7566,7 @@ function briefHidden(brief) {
|
|
|
7481
7566
|
}
|
|
7482
7567
|
var BRIEF_BARNACLES_SHOWN, BRIEF_QUIET, BRIEF_LATE_SHOWN;
|
|
7483
7568
|
var init_brief = __esmMin((() => {
|
|
7569
|
+
init_thanks();
|
|
7484
7570
|
init_date_fns();
|
|
7485
7571
|
init_date_text();
|
|
7486
7572
|
init_derived_health();
|
|
@@ -7879,9 +7965,12 @@ function listed(prefix, items) {
|
|
|
7879
7965
|
const rest = items.length - shown.length;
|
|
7880
7966
|
return {
|
|
7881
7967
|
text: `${prefix}${shown.join("; ")}${rest > 0 ? `; and ${rest} more` : ""}`,
|
|
7882
|
-
more: rest
|
|
7968
|
+
more: rest,
|
|
7969
|
+
items
|
|
7883
7970
|
};
|
|
7884
7971
|
}
|
|
7972
|
+
/** A line's heading for its full list: "Finished: …" reads "Finished". */
|
|
7973
|
+
const changeHeading = (c) => c.text.split(":")[0];
|
|
7885
7974
|
const highRated = (r) => r.kind !== "decision" && isActive(r) && (r.severity === "high" || (r.impact ?? 0) >= 4);
|
|
7886
7975
|
/** What changed on one project since `since`: health, work finished, late or added, the register, the hill. */
|
|
7887
7976
|
function projectChanges(doc, project, since, now) {
|
|
@@ -8073,9 +8162,11 @@ const WORD = {
|
|
|
8073
8162
|
/**
|
|
8074
8163
|
* Bridge mode, as on the website's review: the meeting one project at a
|
|
8075
8164
|
* time with only what changed since the program was last reviewed (←→ or
|
|
8076
|
-
* j k), d a decision, a an action, r a risk, m the program's next move,
|
|
8077
|
-
*
|
|
8078
|
-
*
|
|
8165
|
+
* j k), d a decision, a an action, r a risk, m the program's next move, v
|
|
8166
|
+
* every item behind an "and N more" (↑↓ scroll), e ends it. Esc or q leaves
|
|
8167
|
+
* without ending, so the next meeting still counts from the last one. The
|
|
8168
|
+
* summary lists what was logged; p posts it to each program's chat channel
|
|
8169
|
+
* where one is set, y copies it.
|
|
8079
8170
|
*/
|
|
8080
8171
|
function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
8081
8172
|
const { store, transport, today } = useDeps();
|
|
@@ -8110,6 +8201,12 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8110
8201
|
return bridgeSlides(store.getState(), picked, started);
|
|
8111
8202
|
});
|
|
8112
8203
|
const [index, setIndex] = useState(0);
|
|
8204
|
+
const [all, setAll] = useState(false);
|
|
8205
|
+
const [scroll, setScroll] = useState(0);
|
|
8206
|
+
const move = (to) => {
|
|
8207
|
+
setIndex(to);
|
|
8208
|
+
setScroll(0);
|
|
8209
|
+
};
|
|
8113
8210
|
const [logged, setLogged] = useState([]);
|
|
8114
8211
|
const [ended, setEnded] = useState(slides.length === 0 ? started : null);
|
|
8115
8212
|
const [channels, setChannels] = useState({});
|
|
@@ -8231,9 +8328,19 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8231
8328
|
if (input === "p" && !viewOnly) return void post();
|
|
8232
8329
|
return;
|
|
8233
8330
|
}
|
|
8234
|
-
if (
|
|
8235
|
-
if (key.
|
|
8236
|
-
|
|
8331
|
+
if (input === "e") return end();
|
|
8332
|
+
if (key.escape || input === "q") {
|
|
8333
|
+
go.notify("Left the meeting without ending it; the next one still counts from the last.");
|
|
8334
|
+
return onBack();
|
|
8335
|
+
}
|
|
8336
|
+
if (key.rightArrow || input === "j" || input === " ") return move((i) => Math.min(slides.length - 1, i + 1));
|
|
8337
|
+
if (key.leftArrow || input === "k") return move((i) => Math.max(0, i - 1));
|
|
8338
|
+
if (input === "v") {
|
|
8339
|
+
setScroll(0);
|
|
8340
|
+
return setAll((a) => !a);
|
|
8341
|
+
}
|
|
8342
|
+
if (key.downArrow) return setScroll((n) => n + 1);
|
|
8343
|
+
if (key.upArrow) return setScroll((n) => Math.max(0, n - 1));
|
|
8237
8344
|
if (viewOnly) return;
|
|
8238
8345
|
if (input === "d") return logDecision();
|
|
8239
8346
|
if (input === "a") return logAction();
|
|
@@ -8280,7 +8387,7 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8280
8387
|
slide.milestone ? `Next milestone: ${slide.milestone.name}, ${shortDay(slide.milestone.dueDate, today())}` : "",
|
|
8281
8388
|
"",
|
|
8282
8389
|
`Since ${program.reviewedAt ? "the last review" : "last week"}, ${timeAgo(new Date(slide.since), started)}:`,
|
|
8283
|
-
...slide.changes.
|
|
8390
|
+
...slide.changes.flatMap((c) => all && c.more > 0 && c.items ? [`• ${changeHeading(c)} (${c.items.length}):`, ...c.items.map((i) => ` ${i}`)] : [`• ${c.text}`]),
|
|
8284
8391
|
...here.length ? [
|
|
8285
8392
|
"",
|
|
8286
8393
|
"Logged in this meeting:",
|
|
@@ -8289,7 +8396,11 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8289
8396
|
"",
|
|
8290
8397
|
`Next move: ${program.nextMove?.trim() || "none yet"}`
|
|
8291
8398
|
].filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n");
|
|
8292
|
-
const
|
|
8399
|
+
const room = Math.max(1, height - 3);
|
|
8400
|
+
const wrapped = wrapLines(text, w);
|
|
8401
|
+
const top = Math.min(scroll, Math.max(0, wrapped.length - room));
|
|
8402
|
+
const lines = wrapped.slice(top, top + room);
|
|
8403
|
+
const hasMore = slide.changes.some((c) => c.more > 0);
|
|
8293
8404
|
return /* @__PURE__ */ jsxs(Box, {
|
|
8294
8405
|
flexDirection: "column",
|
|
8295
8406
|
paddingX: 1,
|
|
@@ -8312,7 +8423,8 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8312
8423
|
children: [
|
|
8313
8424
|
"←→ projects",
|
|
8314
8425
|
viewOnly ? "" : " · d decision · a action · r risk · m next move",
|
|
8315
|
-
" ·
|
|
8426
|
+
hasMore ? all ? " · v fewer · ↑↓ scroll" : " · v show all" : "",
|
|
8427
|
+
" · e end · q leave"
|
|
8316
8428
|
]
|
|
8317
8429
|
})
|
|
8318
8430
|
]
|
|
@@ -8383,7 +8495,7 @@ function roomFor(store, projectId, status) {
|
|
|
8383
8495
|
//#endregion
|
|
8384
8496
|
//#region src/tui/views/common.tsx
|
|
8385
8497
|
/** One task on one line: key, title, due, priority, and where it lives. */
|
|
8386
|
-
function TaskLine({ task, projects, people, today, selected, width, showProject = true, showAssignee = false, blocked = false }) {
|
|
8498
|
+
function TaskLine({ task, projects, people, today, selected, picked = false, width, showProject = true, showAssignee = false, blocked = false }) {
|
|
8387
8499
|
const late = isLate(task, today);
|
|
8388
8500
|
const project = showProject ? projects.find((p) => p.id === task.projectId) : null;
|
|
8389
8501
|
const who = showAssignee ? people?.find((p) => p.id === task.assigneeId) : null;
|
|
@@ -8394,7 +8506,11 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
|
|
|
8394
8506
|
bold: selected,
|
|
8395
8507
|
wrap: "truncate-end",
|
|
8396
8508
|
children: [
|
|
8397
|
-
selected ? "›
|
|
8509
|
+
selected ? "›" : " ",
|
|
8510
|
+
picked ? /* @__PURE__ */ jsx(Text, {
|
|
8511
|
+
color: theme.accent,
|
|
8512
|
+
children: "✓"
|
|
8513
|
+
}) : " ",
|
|
8398
8514
|
/* @__PURE__ */ jsx(Text, {
|
|
8399
8515
|
color: theme.muted,
|
|
8400
8516
|
children: keyOf$1({ projects }, task).padEnd(7)
|
|
@@ -9903,7 +10019,7 @@ function byTask(rows, who) {
|
|
|
9903
10019
|
}
|
|
9904
10020
|
return out;
|
|
9905
10021
|
}
|
|
9906
|
-
const plural$
|
|
10022
|
+
const plural$4 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
9907
10023
|
/** Work only one person has touched, soonest-needed first. `scope` narrows it to some programs. */
|
|
9908
10024
|
function lifeboats(data, now, opts = {}) {
|
|
9909
10025
|
const today = isoDay(now);
|
|
@@ -9941,7 +10057,7 @@ function lifeboats(data, now, opts = {}) {
|
|
|
9941
10057
|
kind: "project",
|
|
9942
10058
|
id: project.id,
|
|
9943
10059
|
label: project.name,
|
|
9944
|
-
detail: `${plural$
|
|
10060
|
+
detail: `${plural$4(open.length, "open task")}, nobody else on them`,
|
|
9945
10061
|
soloId: solo[0],
|
|
9946
10062
|
projectId: project.id,
|
|
9947
10063
|
programId: project.programId,
|
|
@@ -9963,7 +10079,7 @@ function lifeboats(data, now, opts = {}) {
|
|
|
9963
10079
|
out.push({
|
|
9964
10080
|
kind: "tasks",
|
|
9965
10081
|
id: `tasks:${project.id}:${soloId}`,
|
|
9966
|
-
label: `${plural$
|
|
10082
|
+
label: `${plural$4(list.length, "task")} on ${project.name}`,
|
|
9967
10083
|
detail: `only ${first} has touched them`,
|
|
9968
10084
|
soloId,
|
|
9969
10085
|
projectId: project.id,
|
|
@@ -9983,7 +10099,7 @@ function lifeboats(data, now, opts = {}) {
|
|
|
9983
10099
|
kind: "task",
|
|
9984
10100
|
id: t.id,
|
|
9985
10101
|
label: key ? `${key} ${t.title}` : t.title,
|
|
9986
|
-
detail: [d < today ? `late since ${md(d)}` : `due ${md(d)}`, holds ? `${plural$
|
|
10102
|
+
detail: [d < today ? `late since ${md(d)}` : `due ${md(d)}`, holds ? `${plural$4(holds, "task")} ${holds === 1 ? "waits" : "wait"} on it` : "urgent"].join(", "),
|
|
9987
10103
|
soloId,
|
|
9988
10104
|
projectId: project.id,
|
|
9989
10105
|
programId: project.programId,
|
|
@@ -10010,7 +10126,7 @@ function lifeboats(data, now, opts = {}) {
|
|
|
10010
10126
|
const programId = first.programId ?? project?.programId ?? null;
|
|
10011
10127
|
const where = project?.name ?? (programId ? programs.get(programId)?.name : null) ?? null;
|
|
10012
10128
|
const issues = items.filter((r) => r.kind === "issue").length;
|
|
10013
|
-
const noun = issues === 0 ? plural$
|
|
10129
|
+
const noun = issues === 0 ? plural$4(items.length, "risk") : issues === items.length ? plural$4(items.length, "issue") : `${plural$4(items.length - issues, "risk")} and ${plural$4(issues, "issue")}`;
|
|
10014
10130
|
out.push({
|
|
10015
10131
|
kind: "risks",
|
|
10016
10132
|
id: key,
|
|
@@ -10340,7 +10456,7 @@ function decisionFields(item, store, go, today) {
|
|
|
10340
10456
|
if (kind === null) return setRevisit(null);
|
|
10341
10457
|
if (kind === "date") return go.ask("Look again on (today, fri, +28 or 2026-11-02)", (text) => {
|
|
10342
10458
|
if (!text.trim()) return;
|
|
10343
|
-
const day = readDue(text, today);
|
|
10459
|
+
const day = readDue$1(text, today);
|
|
10344
10460
|
if (day === null) return go.notify("Can't read that date. Try +28 or 2026-11-02.");
|
|
10345
10461
|
setRevisit(day ? {
|
|
10346
10462
|
kind: "date",
|
|
@@ -18503,13 +18619,13 @@ function milestonePole(milestone, data, now) {
|
|
|
18503
18619
|
undated: first.pole.kind === "chain" ? first.pole.undated : 0
|
|
18504
18620
|
};
|
|
18505
18621
|
}
|
|
18506
|
-
const plural$
|
|
18622
|
+
const plural$3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
18507
18623
|
/** A link's slack in a few words: "4 days spare", "no slack", "3 days late for what's next". */
|
|
18508
18624
|
function slackText(slack, last = false) {
|
|
18509
18625
|
if (slack == null) return last ? "no date to hold" : "next has no due date";
|
|
18510
|
-
if (slack > 0) return `${plural$
|
|
18626
|
+
if (slack > 0) return `${plural$3(slack, "day")} spare`;
|
|
18511
18627
|
if (slack === 0) return "no slack";
|
|
18512
|
-
return last ? `runs ${plural$
|
|
18628
|
+
return last ? `runs ${plural$3(-slack, "day")} over` : `pushes the next ${plural$3(-slack, "day")}`;
|
|
18513
18629
|
}
|
|
18514
18630
|
/**
|
|
18515
18631
|
* The sentence above the line: what sets the date and how it compares with
|
|
@@ -18521,9 +18637,9 @@ function poleHeadline(pole, name, held) {
|
|
|
18521
18637
|
const lead = pole.via ? `${pole.via.project.name} finishes ${shortDate(pole.finish)}` : `${last ? name(last.task) : "It"} can finish ${shortDate(pole.finish)}`;
|
|
18522
18638
|
const tail = pole.via && last && last.finish < pole.finish ? ` Its tasks can all be done by ${shortDate(last.finish)}; its end date sets it.` : "";
|
|
18523
18639
|
if (pole.over == null || !pole.target) return `${lead}.${tail}`;
|
|
18524
|
-
if (pole.over > 0) return `${lead}, ${plural$
|
|
18640
|
+
if (pole.over > 0) return `${lead}, ${plural$3(pole.over, "day")} after ${held} (${shortDate(pole.target)}).${tail}`;
|
|
18525
18641
|
if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
|
|
18526
|
-
return `${lead}, ${plural$
|
|
18642
|
+
return `${lead}, ${plural$3(-pole.over, "day")} before ${held}.${tail}`;
|
|
18527
18643
|
}
|
|
18528
18644
|
/** "13 open tasks have no due date, so they can't hold the date."; null when none. */
|
|
18529
18645
|
function undatedText(pole) {
|
|
@@ -18846,14 +18962,17 @@ function weekPlan(data, personId, monday) {
|
|
|
18846
18962
|
};
|
|
18847
18963
|
});
|
|
18848
18964
|
const placed = new Set(inWeek.map((r) => r.taskId));
|
|
18849
|
-
const
|
|
18965
|
+
const open = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done" && !placed.has(t.id));
|
|
18966
|
+
const unplanned = open.filter((t) => t.status !== "review").sort(byDue);
|
|
18967
|
+
const inReview = open.filter((t) => t.status === "review").sort(byDue);
|
|
18850
18968
|
return {
|
|
18851
18969
|
monday,
|
|
18852
18970
|
days,
|
|
18853
18971
|
dayHours,
|
|
18854
18972
|
planned: days.reduce((sum, d) => sum + d.hours, 0),
|
|
18855
18973
|
capacity: dayHours * 5,
|
|
18856
|
-
unplanned
|
|
18974
|
+
unplanned,
|
|
18975
|
+
inReview
|
|
18857
18976
|
};
|
|
18858
18977
|
}
|
|
18859
18978
|
/** Planned against done for a week: what was on the plan, what got finished, and what came in besides. */
|
|
@@ -18921,6 +19040,7 @@ var init_passage = __esmMin((() => {
|
|
|
18921
19040
|
|
|
18922
19041
|
//#endregion
|
|
18923
19042
|
//#region src/lib/mcp/tools.ts
|
|
19043
|
+
init_thanks();
|
|
18924
19044
|
init_task_keys();
|
|
18925
19045
|
init_brief();
|
|
18926
19046
|
init_indexes();
|
|
@@ -19191,6 +19311,8 @@ function taskLine(t, people, projects, all) {
|
|
|
19191
19311
|
};
|
|
19192
19312
|
}
|
|
19193
19313
|
/** One person's week plan as the agent tools give it (get_week, plan_week). */
|
|
19314
|
+
/** Most of the tasks not on a day that get_week lists; the count says how many there are. */
|
|
19315
|
+
const UNPLANNED_MAX = 200;
|
|
19194
19316
|
function weekOut(data, personId, monday) {
|
|
19195
19317
|
const people = byId(data.people);
|
|
19196
19318
|
const projects = byId(data.projects);
|
|
@@ -19215,8 +19337,9 @@ function weekOut(data, personId, monday) {
|
|
|
19215
19337
|
...taskLine(item.task, people, projects, data.tasks)
|
|
19216
19338
|
}))
|
|
19217
19339
|
})),
|
|
19218
|
-
unplanned: plan.unplanned.slice(0,
|
|
19340
|
+
unplanned: plan.unplanned.slice(0, 200).map((t) => taskLine(t, people, projects, data.tasks)),
|
|
19219
19341
|
unplannedCount: plan.unplanned.length,
|
|
19342
|
+
inReviewCount: plan.inReview.length,
|
|
19220
19343
|
review: {
|
|
19221
19344
|
summary: reviewText(review),
|
|
19222
19345
|
planned: review.planned.length,
|
|
@@ -19924,6 +20047,51 @@ const HARBOR_TOOLS = [
|
|
|
19924
20047
|
};
|
|
19925
20048
|
}
|
|
19926
20049
|
}),
|
|
20050
|
+
tool({
|
|
20051
|
+
name: "review_meeting",
|
|
20052
|
+
title: "What changed since the last review",
|
|
20053
|
+
description: "The review meeting's agenda (bridge mode): for each open program, the open projects where something moved since the program was last reviewed (a week back if never): health moves, work finished, newly late or added, decisions, risks raised or updated, hill moves. Each change has its line and, where the line says \"and N more\", every item. Projects where nothing moved are listed as quiet. Reading this doesn't count as holding the meeting." + DATA_NOTE,
|
|
20054
|
+
input: object({
|
|
20055
|
+
harbor: harborArg,
|
|
20056
|
+
program: string().min(1).max(200).optional().describe("Program id or name; every open program when left out.")
|
|
20057
|
+
}),
|
|
20058
|
+
run: (ctx, input) => {
|
|
20059
|
+
const { data } = ctx;
|
|
20060
|
+
const programs = input.program ? [findOne(data.programs, input.program, "program")] : reviewPrograms(data).filter((p) => p.health !== "done");
|
|
20061
|
+
const { slides, quiet } = bridgeSlides(data, programs, ctx.now);
|
|
20062
|
+
const people = byId(data.people);
|
|
20063
|
+
return {
|
|
20064
|
+
programs: programs.filter((p) => p.health !== "done").map((p) => ({
|
|
20065
|
+
name: p.name,
|
|
20066
|
+
since: bridgeSince(p, ctx.now),
|
|
20067
|
+
lastReviewed: p.reviewedAt ?? null
|
|
20068
|
+
})),
|
|
20069
|
+
projects: slides.map((s) => ({
|
|
20070
|
+
program: s.program.name,
|
|
20071
|
+
project: s.project.name,
|
|
20072
|
+
health: s.project.health,
|
|
20073
|
+
lead: nameOf$2(people, s.project.ownerId),
|
|
20074
|
+
ends: dayOf(s.project.endDate),
|
|
20075
|
+
late: s.late,
|
|
20076
|
+
open: s.open,
|
|
20077
|
+
highRisks: s.highRisks,
|
|
20078
|
+
nextMilestone: s.milestone ? {
|
|
20079
|
+
name: s.milestone.name,
|
|
20080
|
+
due: dayOf(s.milestone.dueDate)
|
|
20081
|
+
} : null,
|
|
20082
|
+
changes: s.changes.map((c) => ({
|
|
20083
|
+
kind: c.kind,
|
|
20084
|
+
text: c.text,
|
|
20085
|
+
...c.more > 0 && c.items ? { items: c.items } : {}
|
|
20086
|
+
}))
|
|
20087
|
+
})),
|
|
20088
|
+
quiet: quiet.map((q) => ({
|
|
20089
|
+
program: q.program.name,
|
|
20090
|
+
project: q.project.name
|
|
20091
|
+
}))
|
|
20092
|
+
};
|
|
20093
|
+
}
|
|
20094
|
+
}),
|
|
19927
20095
|
tool({
|
|
19928
20096
|
name: "get_premortem",
|
|
19929
20097
|
title: "A project's pre-mortem",
|
|
@@ -20025,6 +20193,11 @@ const HARBOR_TOOLS = [
|
|
|
20025
20193
|
who: c.person.name,
|
|
20026
20194
|
tasks: c.closed
|
|
20027
20195
|
})),
|
|
20196
|
+
thanks: log.thanks.map((t) => ({
|
|
20197
|
+
from: nameOf$2(people, t.authorId),
|
|
20198
|
+
to: nameOf$2(people, t.toId ?? null),
|
|
20199
|
+
line: t.body
|
|
20200
|
+
})),
|
|
20028
20201
|
crew: log.lines.map((l) => ({
|
|
20029
20202
|
who: nameOf$2(people, l.authorId),
|
|
20030
20203
|
line: l.body
|
|
@@ -20177,6 +20350,41 @@ const HARBOR_TOOLS = [
|
|
|
20177
20350
|
return answer("project", project.name, projectPole(project, data.tasks, ctx.now), "the end date");
|
|
20178
20351
|
}
|
|
20179
20352
|
}),
|
|
20353
|
+
tool({
|
|
20354
|
+
name: "list_thanks",
|
|
20355
|
+
title: "Thanks people were given",
|
|
20356
|
+
description: "Fair winds: the one-line thanks given for tasks, milestones and decisions, newest first, each with who gave it, who it was for and what for. By default the ones you were given in the last 30 days; person narrows to someone else's (\"all\" for everyone's), given: true lists the ones they gave instead. There are no counts or rankings, by design." + DATA_NOTE,
|
|
20357
|
+
input: object({
|
|
20358
|
+
harbor: harborArg,
|
|
20359
|
+
person: string().max(200).optional().describe("\"me\" (the default), \"all\", or a person's id or name."),
|
|
20360
|
+
given: boolean().default(false).describe("The thanks they gave, not the ones they got."),
|
|
20361
|
+
days: number().int().min(1).max(365).default(30)
|
|
20362
|
+
}),
|
|
20363
|
+
run: (ctx, input) => {
|
|
20364
|
+
const { data } = ctx;
|
|
20365
|
+
const key = (input.person ?? "me").trim().toLowerCase();
|
|
20366
|
+
const who = key === "all" ? null : key === "me" ? me(ctx) : findOne(data.people, input.person, "person");
|
|
20367
|
+
if (key === "me" && !who) return {
|
|
20368
|
+
thanks: [],
|
|
20369
|
+
note: "You aren't one of this harbor's people yet, so nobody has thanked you here."
|
|
20370
|
+
};
|
|
20371
|
+
const since = (/* @__PURE__ */ new Date(ctx.now.getTime() - input.days * 864e5)).toISOString();
|
|
20372
|
+
const lines = thanksSince(data, since).filter((l) => !who || (input.given ? l.thanks.authorId : l.thanks.toId) === who.id);
|
|
20373
|
+
const people = byId(data.people);
|
|
20374
|
+
return {
|
|
20375
|
+
...who ? { person: who.name } : {},
|
|
20376
|
+
days: input.days,
|
|
20377
|
+
thanks: lines.slice(0, 100).map((l) => ({
|
|
20378
|
+
from: nameOf$2(people, l.thanks.authorId),
|
|
20379
|
+
to: nameOf$2(people, l.thanks.toId ?? null),
|
|
20380
|
+
for: l.for,
|
|
20381
|
+
kind: l.thanks.thanksFor.kind,
|
|
20382
|
+
line: l.thanks.body,
|
|
20383
|
+
at: l.thanks.createdAt
|
|
20384
|
+
}))
|
|
20385
|
+
};
|
|
20386
|
+
}
|
|
20387
|
+
}),
|
|
20180
20388
|
tool({
|
|
20181
20389
|
name: "scope_growth",
|
|
20182
20390
|
title: "Barnacles: work added since the plan was set",
|
|
@@ -20784,6 +20992,7 @@ function reversalOf(before, after) {
|
|
|
20784
20992
|
}
|
|
20785
20993
|
function labelOf(before, after, forward) {
|
|
20786
20994
|
const lead = LEAD.map((key) => forward.filter((f) => f.key === key)).find((l) => l.length > 0);
|
|
20995
|
+
if (lead && lead.length > 1 && lead.every((f) => f.key === "tasks" && f.change.kind === "set")) return `your change to ${lead.length} tasks`;
|
|
20787
20996
|
if (!lead || lead.length !== 1) return "your last change";
|
|
20788
20997
|
const { key, change } = lead[0];
|
|
20789
20998
|
const find = (doc, id) => (doc[key] ?? []).find((r) => r.id === id);
|
|
@@ -21807,6 +22016,61 @@ function stampAssignment(before, next, by, at) {
|
|
|
21807
22016
|
}
|
|
21808
22017
|
var init_assignment = __esmMin((() => {}));
|
|
21809
22018
|
|
|
22019
|
+
//#endregion
|
|
22020
|
+
//#region src/lib/bulk.ts
|
|
22021
|
+
/** A task's due date moved by `days`, or null when it has none. */
|
|
22022
|
+
function shiftedDue(due, days) {
|
|
22023
|
+
if (!due) return null;
|
|
22024
|
+
return todayISO(addDays(parseIso(`${due.slice(0, 10)}T12:00:00`), days));
|
|
22025
|
+
}
|
|
22026
|
+
/** The task as the change leaves it, before automations and stamps; the same task when nothing moves. */
|
|
22027
|
+
function applyBulk(task, change) {
|
|
22028
|
+
if ("status" in change) return task.status === change.status ? task : {
|
|
22029
|
+
...task,
|
|
22030
|
+
status: change.status
|
|
22031
|
+
};
|
|
22032
|
+
if ("assigneeId" in change) return task.assigneeId === change.assigneeId ? task : {
|
|
22033
|
+
...task,
|
|
22034
|
+
assigneeId: change.assigneeId
|
|
22035
|
+
};
|
|
22036
|
+
if ("dueDate" in change) return task.dueDate === change.dueDate ? task : {
|
|
22037
|
+
...task,
|
|
22038
|
+
dueDate: change.dueDate
|
|
22039
|
+
};
|
|
22040
|
+
if ("shiftDays" in change) {
|
|
22041
|
+
const due = shiftedDue(task.dueDate, change.shiftDays);
|
|
22042
|
+
return due === null || change.shiftDays === 0 ? task : {
|
|
22043
|
+
...task,
|
|
22044
|
+
dueDate: due
|
|
22045
|
+
};
|
|
22046
|
+
}
|
|
22047
|
+
return task.priority === change.priority ? task : {
|
|
22048
|
+
...task,
|
|
22049
|
+
priority: change.priority
|
|
22050
|
+
};
|
|
22051
|
+
}
|
|
22052
|
+
/** What the change did, for the Activity line and the tray's notice: "moved 4 tasks to In play". */
|
|
22053
|
+
function bulkText(change, n, names = {}) {
|
|
22054
|
+
const tasks = plural$2(n);
|
|
22055
|
+
if ("status" in change) return change.status === "done" ? `finished ${tasks}` : `moved ${tasks} to ${TASK_STATUS_LABEL[change.status]}`;
|
|
22056
|
+
if ("assigneeId" in change) return change.assigneeId ? `gave ${tasks} to ${names.person ?? "someone"}` : `unassigned ${tasks}`;
|
|
22057
|
+
if ("dueDate" in change) return change.dueDate ? `set ${tasks} due ${change.dueDate}` : `cleared the due date of ${tasks}`;
|
|
22058
|
+
if ("shiftDays" in change) {
|
|
22059
|
+
const weeks = Math.abs(change.shiftDays) % 7 === 0 ? Math.abs(change.shiftDays) / 7 : null;
|
|
22060
|
+
const by = weeks ? `${weeks} ${weeks === 1 ? "week" : "weeks"}` : `${Math.abs(change.shiftDays)} days`;
|
|
22061
|
+
return `${change.shiftDays > 0 ? "pushed" : "pulled"} ${tasks} ${by} ${change.shiftDays > 0 ? "later" : "earlier"}`;
|
|
22062
|
+
}
|
|
22063
|
+
return `set ${tasks} to ${PRIORITY_LABEL[change.priority].toLowerCase()} priority`;
|
|
22064
|
+
}
|
|
22065
|
+
var plural$2;
|
|
22066
|
+
var init_bulk = __esmMin((() => {
|
|
22067
|
+
init_date_fns();
|
|
22068
|
+
init_date_text();
|
|
22069
|
+
init_format();
|
|
22070
|
+
init_swatches();
|
|
22071
|
+
plural$2 = (n) => `${n} ${n === 1 ? "task" : "tasks"}`;
|
|
22072
|
+
}));
|
|
22073
|
+
|
|
21810
22074
|
//#endregion
|
|
21811
22075
|
//#region src/lib/slices/tasks.ts
|
|
21812
22076
|
function tasksSlice({ set, get, write }) {
|
|
@@ -22128,6 +22392,70 @@ function tasksSlice({ set, get, write }) {
|
|
|
22128
22392
|
} : t) });
|
|
22129
22393
|
get().log(kept.length === 1 ? `kept “${kept[0].title}” out of the fog` : `kept ${kept.length} tasks out of the fog`, kept.length === 1 ? `/projects/${kept[0].projectId}` : void 0);
|
|
22130
22394
|
},
|
|
22395
|
+
editTasks: (ids, change, opts) => {
|
|
22396
|
+
const s = get();
|
|
22397
|
+
const pick = new Set(ids.slice(0, 500));
|
|
22398
|
+
const moving = s.tasks.filter((t) => pick.has(t.id)).filter((t) => applyBulk(t, change) !== t);
|
|
22399
|
+
if (moving.length === 0) return { changed: 0 };
|
|
22400
|
+
if ("status" in change) {
|
|
22401
|
+
if (change.status === "done" && !opts?.allowBlocked) {
|
|
22402
|
+
const blocked = moving.filter((t) => isBlocked$1(t, s.tasks)).length;
|
|
22403
|
+
if (blocked) return {
|
|
22404
|
+
changed: 0,
|
|
22405
|
+
blocked
|
|
22406
|
+
};
|
|
22407
|
+
}
|
|
22408
|
+
if (!opts?.allowOverCap) {
|
|
22409
|
+
const into = /* @__PURE__ */ new Map();
|
|
22410
|
+
for (const t of moving) into.set(t.projectId, (into.get(t.projectId) ?? 0) + 1);
|
|
22411
|
+
for (const [projectId, n] of into) {
|
|
22412
|
+
const overCap = columnWip(s, projectId, change.status, n);
|
|
22413
|
+
if (overCap) return {
|
|
22414
|
+
changed: 0,
|
|
22415
|
+
overCap
|
|
22416
|
+
};
|
|
22417
|
+
}
|
|
22418
|
+
}
|
|
22419
|
+
}
|
|
22420
|
+
const me = s.currentUserId;
|
|
22421
|
+
const at = stamp();
|
|
22422
|
+
const projects = new Map(s.projects.map((p) => [p.id, p]));
|
|
22423
|
+
const newComments = [];
|
|
22424
|
+
const follows = [];
|
|
22425
|
+
const moved = new Set(moving.map((t) => t.id));
|
|
22426
|
+
const tasks = s.tasks.map((row) => {
|
|
22427
|
+
if (!moved.has(row.id)) return row;
|
|
22428
|
+
let next = applyBulk(row, change);
|
|
22429
|
+
if ("status" in change) {
|
|
22430
|
+
next = withStatus(row, change.status);
|
|
22431
|
+
if (change.status === "done" && isBlocked$1(row, s.tasks)) Object.assign(next, blockersPatch([]));
|
|
22432
|
+
}
|
|
22433
|
+
const { task: automated, comments } = applyAutomations(projects.get(row.projectId), row, next, {
|
|
22434
|
+
created: false,
|
|
22435
|
+
currentUserId: me
|
|
22436
|
+
});
|
|
22437
|
+
newComments.push(...comments.map((c) => ({
|
|
22438
|
+
...c,
|
|
22439
|
+
id: uid("c")
|
|
22440
|
+
})));
|
|
22441
|
+
const final = stampAssignment(row, automated, me, at);
|
|
22442
|
+
if (final.status === "done" && row.status !== "done") {
|
|
22443
|
+
const follow = nextOccurrence(final, uid("t"), todayISO(), at);
|
|
22444
|
+
if (follow) follows.push(follow);
|
|
22445
|
+
}
|
|
22446
|
+
return final;
|
|
22447
|
+
});
|
|
22448
|
+
write({
|
|
22449
|
+
tasks: follows.length ? [...tasks, ...follows] : tasks,
|
|
22450
|
+
comments: newComments.length ? [...s.comments, ...newComments] : s.comments,
|
|
22451
|
+
wipPending: null,
|
|
22452
|
+
blockPending: null
|
|
22453
|
+
});
|
|
22454
|
+
const person = "assigneeId" in change ? s.people.find((p) => p.id === change.assigneeId)?.name : null;
|
|
22455
|
+
const where = new Set(moving.map((t) => t.projectId));
|
|
22456
|
+
get().log(bulkText(change, moving.length, { person }), where.size === 1 ? `/projects/${[...where][0]}` : void 0);
|
|
22457
|
+
return { changed: moving.length };
|
|
22458
|
+
},
|
|
22131
22459
|
toggleFocus: (taskId) => {
|
|
22132
22460
|
const personId = get().currentUserId;
|
|
22133
22461
|
const date = todayISO();
|
|
@@ -22231,6 +22559,7 @@ var init_tasks = __esmMin((() => {
|
|
|
22231
22559
|
init_shared();
|
|
22232
22560
|
init_recurrence();
|
|
22233
22561
|
init_assignment();
|
|
22562
|
+
init_bulk();
|
|
22234
22563
|
}));
|
|
22235
22564
|
|
|
22236
22565
|
//#endregion
|
|
@@ -22449,7 +22778,8 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
22449
22778
|
const me = get().currentUserId;
|
|
22450
22779
|
write({ reflections: (get().reflections ?? []).map((r) => {
|
|
22451
22780
|
if (r.id !== id || r.authorId !== me) return r;
|
|
22452
|
-
const
|
|
22781
|
+
const max = r.kind === "bottle" ? BOTTLE_MAX : r.kind === "thanks" ? 200 : 600;
|
|
22782
|
+
const text = body.trim().slice(0, max);
|
|
22453
22783
|
return text ? {
|
|
22454
22784
|
...r,
|
|
22455
22785
|
body: text
|
|
@@ -22519,6 +22849,29 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
22519
22849
|
skipped
|
|
22520
22850
|
} : r) });
|
|
22521
22851
|
},
|
|
22852
|
+
giveThanks: (on, toId, body) => {
|
|
22853
|
+
const s = get();
|
|
22854
|
+
const me = s.currentUserId;
|
|
22855
|
+
const text = body.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
22856
|
+
const target = thanksTarget(s, on);
|
|
22857
|
+
if (!target || thanksProblem(text, toId, me)) return null;
|
|
22858
|
+
if (!s.people.some((p) => p.id === toId)) return null;
|
|
22859
|
+
const row = {
|
|
22860
|
+
id: uid("rf"),
|
|
22861
|
+
projectId: target.projectId,
|
|
22862
|
+
kind: "thanks",
|
|
22863
|
+
authorId: me,
|
|
22864
|
+
body: text,
|
|
22865
|
+
createdAt: stamp(),
|
|
22866
|
+
toId,
|
|
22867
|
+
thanksFor: {
|
|
22868
|
+
kind: on.kind,
|
|
22869
|
+
id: on.id
|
|
22870
|
+
}
|
|
22871
|
+
};
|
|
22872
|
+
write({ reflections: [...s.reflections ?? [], row] });
|
|
22873
|
+
return row.id;
|
|
22874
|
+
},
|
|
22522
22875
|
writeVoyageLine: (projectId, body) => {
|
|
22523
22876
|
const text = body.trim().slice(0, 600);
|
|
22524
22877
|
const me = get().currentUserId;
|
|
@@ -22603,6 +22956,7 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
22603
22956
|
};
|
|
22604
22957
|
}
|
|
22605
22958
|
var init_milestones_raid = __esmMin((() => {
|
|
22959
|
+
init_thanks();
|
|
22606
22960
|
init_bottle_rules();
|
|
22607
22961
|
init_clock();
|
|
22608
22962
|
init_premortem();
|
|
@@ -26588,6 +26942,117 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
26588
26942
|
});
|
|
26589
26943
|
}
|
|
26590
26944
|
|
|
26945
|
+
//#endregion
|
|
26946
|
+
//#region src/tui/bulk.ts
|
|
26947
|
+
init_bulk();
|
|
26948
|
+
init_board();
|
|
26949
|
+
init_swatches();
|
|
26950
|
+
/** "2026-11-04", "today", "tomorrow", "+3", or "none"; undefined when it's none of those. */
|
|
26951
|
+
function readDue(text, today) {
|
|
26952
|
+
const t = text.trim().toLowerCase();
|
|
26953
|
+
if (t === "none" || t === "no date") return null;
|
|
26954
|
+
if (t === "today") return today;
|
|
26955
|
+
if (t === "tomorrow") return shiftedDue(today, 1);
|
|
26956
|
+
if (/^\+\d{1,3}$/.test(t)) return shiftedDue(today, Number(t.slice(1)));
|
|
26957
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return t;
|
|
26958
|
+
}
|
|
26959
|
+
function applyPicked(store, go, ids, change, opts) {
|
|
26960
|
+
const s = store.getState();
|
|
26961
|
+
const r = s.editTasks(ids, change, opts);
|
|
26962
|
+
if (r.blocked) return go.confirm(`${r.blocked === 1 ? "One of them still waits" : `${r.blocked} of them still wait`} on other tasks. Mark them all done anyway?`, () => applyPicked(store, go, ids, change, {
|
|
26963
|
+
...opts,
|
|
26964
|
+
allowBlocked: true
|
|
26965
|
+
}), "Mark done");
|
|
26966
|
+
if (r.overCap) {
|
|
26967
|
+
if (s.myRole !== "owner") return go.notify(wipFullMessage(r.overCap));
|
|
26968
|
+
return go.confirm(`${wipFullMessage(r.overCap)} Move them past the limit?`, () => applyPicked(store, go, ids, change, {
|
|
26969
|
+
...opts,
|
|
26970
|
+
allowOverCap: true
|
|
26971
|
+
}), "Move anyway");
|
|
26972
|
+
}
|
|
26973
|
+
if (r.changed === 0) return go.notify("Nothing to change: they're all like that already.");
|
|
26974
|
+
const person = "assigneeId" in change ? s.people.find((p) => p.id === change.assigneeId)?.name : null;
|
|
26975
|
+
const text = bulkText(change, r.changed, { person });
|
|
26976
|
+
go.notify(`${text[0].toUpperCase()}${text.slice(1)}. u undoes it.`);
|
|
26977
|
+
}
|
|
26978
|
+
/** What m offers for the picked tasks; `done` runs after a change so the view can let them go. */
|
|
26979
|
+
function bulkMenu(store, go, ids, today, clear) {
|
|
26980
|
+
const run = (change) => applyPicked(store, go, ids, change);
|
|
26981
|
+
const n = ids.length;
|
|
26982
|
+
go.pick(`${n} picked`, [
|
|
26983
|
+
{
|
|
26984
|
+
label: "Move to…",
|
|
26985
|
+
value: "move"
|
|
26986
|
+
},
|
|
26987
|
+
{
|
|
26988
|
+
label: "Assign to…",
|
|
26989
|
+
value: "assign"
|
|
26990
|
+
},
|
|
26991
|
+
{
|
|
26992
|
+
label: "Due date…",
|
|
26993
|
+
value: "due"
|
|
26994
|
+
},
|
|
26995
|
+
{
|
|
26996
|
+
label: "Push a week later",
|
|
26997
|
+
value: "push",
|
|
26998
|
+
hint: "undated ones stay"
|
|
26999
|
+
},
|
|
27000
|
+
{
|
|
27001
|
+
label: "Priority…",
|
|
27002
|
+
value: "priority"
|
|
27003
|
+
},
|
|
27004
|
+
{
|
|
27005
|
+
label: "Mark done",
|
|
27006
|
+
value: "done"
|
|
27007
|
+
},
|
|
27008
|
+
{
|
|
27009
|
+
label: "Let go of them",
|
|
27010
|
+
value: "clear",
|
|
27011
|
+
hint: "esc"
|
|
27012
|
+
}
|
|
27013
|
+
], (what) => {
|
|
27014
|
+
if (what === "clear") return clear();
|
|
27015
|
+
if (what === "push") return run({ shiftDays: 7 });
|
|
27016
|
+
if (what === "done") return run({ status: "done" });
|
|
27017
|
+
if (what === "move") return go.pick(`Move ${n} to`, TASK_COLUMNS.map((st) => ({
|
|
27018
|
+
label: TASK_STATUS_LABEL[st],
|
|
27019
|
+
value: st
|
|
27020
|
+
})), (status) => run({ status }));
|
|
27021
|
+
if (what === "priority") return go.pick(`Priority for ${n}`, [
|
|
27022
|
+
"urgent",
|
|
27023
|
+
"high",
|
|
27024
|
+
"medium",
|
|
27025
|
+
"low"
|
|
27026
|
+
].map((p) => ({
|
|
27027
|
+
label: PRIORITY_LABEL[p],
|
|
27028
|
+
value: p
|
|
27029
|
+
})), (priority) => run({ priority }));
|
|
27030
|
+
if (what === "assign") {
|
|
27031
|
+
const s = store.getState();
|
|
27032
|
+
const crew = [...s.people].filter((p) => p.id !== s.currentUserId).sort((a, b) => a.name.localeCompare(b.name));
|
|
27033
|
+
return go.pick(`Give ${n} to`, [
|
|
27034
|
+
{
|
|
27035
|
+
label: "Me",
|
|
27036
|
+
value: s.currentUserId
|
|
27037
|
+
},
|
|
27038
|
+
{
|
|
27039
|
+
label: "Nobody",
|
|
27040
|
+
value: null
|
|
27041
|
+
},
|
|
27042
|
+
...crew.map((p) => ({
|
|
27043
|
+
label: p.name,
|
|
27044
|
+
value: p.id
|
|
27045
|
+
}))
|
|
27046
|
+
], (who) => run({ assigneeId: who }));
|
|
27047
|
+
}
|
|
27048
|
+
if (what === "due") return go.ask("Due (YYYY-MM-DD, today, tomorrow, +7 or none)", (text) => {
|
|
27049
|
+
const due = readDue(text, today);
|
|
27050
|
+
if (due === void 0) return go.notify(`"${text}" isn't a date Harbor reads.`);
|
|
27051
|
+
run({ dueDate: due });
|
|
27052
|
+
});
|
|
27053
|
+
});
|
|
27054
|
+
}
|
|
27055
|
+
|
|
26591
27056
|
//#endregion
|
|
26592
27057
|
//#region src/tui/views/project.tsx
|
|
26593
27058
|
init_swatches();
|
|
@@ -26641,6 +27106,9 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
26641
27106
|
const [col, setCol] = useState(() => Math.max(0, columns.findIndex((c) => c.tasks.length > 0)));
|
|
26642
27107
|
const [rowByCol, setRowByCol] = useState({});
|
|
26643
27108
|
const [listAt, setListAt] = useState(0);
|
|
27109
|
+
const [pickedIds, setPicked] = useState([]);
|
|
27110
|
+
const live = useMemo(() => new Set(tasks.map((t) => t.id)), [tasks]);
|
|
27111
|
+
const picked = pickedIds.filter((id) => live.has(id));
|
|
26644
27112
|
const day = today();
|
|
26645
27113
|
const fogWeeks = (t) => {
|
|
26646
27114
|
if (t.status === "done") return null;
|
|
@@ -26670,7 +27138,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
26670
27138
|
}
|
|
26671
27139
|
};
|
|
26672
27140
|
useInput((input, key) => {
|
|
27141
|
+
if (key.escape && picked.length) return setPicked([]);
|
|
26673
27142
|
if (key.escape || key.backspace) return onBack();
|
|
27143
|
+
if (input === " " && selected && mode !== "budget") return setPicked((ids) => ids.includes(selected.id) ? ids.filter((x) => x !== selected.id) : [...ids, selected.id]);
|
|
27144
|
+
if (input === "m" && picked.length && mode !== "budget") return bulkMenu(store, go, picked, day, () => setPicked([]));
|
|
26674
27145
|
if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : "board");
|
|
26675
27146
|
if (input === "e") return go.openPanel("project", projectId);
|
|
26676
27147
|
if (input === "b") return go.openPanel("board", projectId);
|
|
@@ -26825,6 +27296,16 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
26825
27296
|
]
|
|
26826
27297
|
}) : null,
|
|
26827
27298
|
" ",
|
|
27299
|
+
picked.length && mode !== "budget" ? /* @__PURE__ */ jsxs(Text, {
|
|
27300
|
+
color: theme.accent,
|
|
27301
|
+
children: [
|
|
27302
|
+
" ",
|
|
27303
|
+
"· ",
|
|
27304
|
+
picked.length,
|
|
27305
|
+
" picked: m changes them, esc lets go"
|
|
27306
|
+
]
|
|
27307
|
+
}) : " · space picks",
|
|
27308
|
+
" ",
|
|
26828
27309
|
"· v next · f p filter · e details · b setup"
|
|
26829
27310
|
]
|
|
26830
27311
|
})
|
|
@@ -26959,7 +27440,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
26959
27440
|
waits: blocked(data, t),
|
|
26960
27441
|
priority: t.priority,
|
|
26961
27442
|
selected: on && view.start + i === r,
|
|
26962
|
-
fog: fogWeeks(t)
|
|
27443
|
+
fog: fogWeeks(t),
|
|
27444
|
+
picked: picked.includes(t.id)
|
|
26963
27445
|
}, t.id)),
|
|
26964
27446
|
view.start + view.items.length < c.tasks.length ? /* @__PURE__ */ jsxs(Text, {
|
|
26965
27447
|
color: theme.muted,
|
|
@@ -26989,6 +27471,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
26989
27471
|
people,
|
|
26990
27472
|
today: day,
|
|
26991
27473
|
selected: active && start + i === listAt,
|
|
27474
|
+
picked: picked.includes(t.id),
|
|
26992
27475
|
width: width - 12,
|
|
26993
27476
|
showProject: false,
|
|
26994
27477
|
showAssignee: true,
|
|
@@ -27000,7 +27483,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
27000
27483
|
});
|
|
27001
27484
|
}
|
|
27002
27485
|
/** One card on the board: its key, due date and owner, then the title on up to two lines. */
|
|
27003
|
-
function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog = null }) {
|
|
27486
|
+
function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog = null, picked = false }) {
|
|
27004
27487
|
return /* @__PURE__ */ jsxs(Box, {
|
|
27005
27488
|
flexDirection: "column",
|
|
27006
27489
|
height: 3,
|
|
@@ -27009,6 +27492,10 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog
|
|
|
27009
27492
|
inverse: selected,
|
|
27010
27493
|
bold: selected,
|
|
27011
27494
|
children: [
|
|
27495
|
+
picked ? /* @__PURE__ */ jsx(Text, {
|
|
27496
|
+
color: selected ? void 0 : theme.accent,
|
|
27497
|
+
children: "✓ "
|
|
27498
|
+
}) : null,
|
|
27012
27499
|
/* @__PURE__ */ jsx(Text, {
|
|
27013
27500
|
color: selected ? void 0 : theme.muted,
|
|
27014
27501
|
children: taskKey
|
|
@@ -28264,7 +28751,7 @@ function DueField({ initial, today, onSubmit, onCancel }) {
|
|
|
28264
28751
|
hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
|
|
28265
28752
|
onCancel,
|
|
28266
28753
|
onSubmit: (text) => {
|
|
28267
|
-
const due = readDue(text, today);
|
|
28754
|
+
const due = readDue$1(text, today);
|
|
28268
28755
|
if (due == null) setError(`“${text}” isn't a date Harbor can read.`);
|
|
28269
28756
|
else onSubmit(due);
|
|
28270
28757
|
}
|
|
@@ -28772,7 +29259,7 @@ function App({ ui, startIn }) {
|
|
|
28772
29259
|
const covered = overlay !== null || blockPending !== null;
|
|
28773
29260
|
const page = stack.at(-1);
|
|
28774
29261
|
useInput((input, key) => {
|
|
28775
|
-
if (key.ctrl && input === "c" || input === "q") return void quit();
|
|
29262
|
+
if (key.ctrl && input === "c" || input === "q" && page?.kind !== "bridge") return void quit();
|
|
28776
29263
|
if (key.tab) return setFocus((f) => f === "nav" ? "main" : "nav");
|
|
28777
29264
|
if (input === "/") return setOverlay({ kind: "search" });
|
|
28778
29265
|
if (input === ":") return setOverlay({ kind: "palette" });
|
|
@@ -28998,13 +29485,15 @@ function App({ ui, startIn }) {
|
|
|
28998
29485
|
["←→", "projects"],
|
|
28999
29486
|
["d a r", "log"],
|
|
29000
29487
|
["e", "end"],
|
|
29001
|
-
["
|
|
29488
|
+
["q", "leave"]
|
|
29002
29489
|
] : page?.kind === "project" ? [
|
|
29003
29490
|
["←→↑↓", "move"],
|
|
29004
29491
|
["Enter", "open"],
|
|
29005
29492
|
["n", "new"],
|
|
29006
29493
|
["[ ]", "move card"],
|
|
29007
29494
|
["x", "done"],
|
|
29495
|
+
["space", "pick"],
|
|
29496
|
+
["m", "change picked"],
|
|
29008
29497
|
["d", "delete"],
|
|
29009
29498
|
["v", "board/list/budget"],
|
|
29010
29499
|
["f p", "filter"],
|
|
@@ -29368,6 +29857,7 @@ function fogSummary(t) {
|
|
|
29368
29857
|
|
|
29369
29858
|
//#endregion
|
|
29370
29859
|
//#region src/tui/watch.ts
|
|
29860
|
+
init_date_text();
|
|
29371
29861
|
init_indexes();
|
|
29372
29862
|
init_swatches();
|
|
29373
29863
|
/** Most lines one change prints; a whole reload says how many more there were. */
|
|
@@ -29484,6 +29974,43 @@ function watchLines(prev, next, meId) {
|
|
|
29484
29974
|
}
|
|
29485
29975
|
return out;
|
|
29486
29976
|
}
|
|
29977
|
+
/** How many of the latest changes `harbor watch` starts with. */
|
|
29978
|
+
const WATCH_RECENT = 5;
|
|
29979
|
+
/**
|
|
29980
|
+
* The harbor's latest changes from its activity log, oldest first, so a
|
|
29981
|
+
* watch doesn't start on a blank screen: the last few, or every one since
|
|
29982
|
+
* `since` (an ISO moment). With a project, only lines about it.
|
|
29983
|
+
*/
|
|
29984
|
+
function recentLines(doc, meId, opts = {}) {
|
|
29985
|
+
const people = byId(doc.people);
|
|
29986
|
+
const from = opts.since ? Date.parse(opts.since) : Number.NEGATIVE_INFINITY;
|
|
29987
|
+
const rows = doc.activity.filter((a) => Date.parse(a.at) > from).filter((a) => !opts.projectId || a.href === `/projects/${opts.projectId}`).sort((a, b) => a.at.localeCompare(b.at));
|
|
29988
|
+
return (opts.since ? rows : rows.slice(-(opts.limit ?? 5))).map((a) => ({
|
|
29989
|
+
at: a.at,
|
|
29990
|
+
line: {
|
|
29991
|
+
text: `${a.personId === meId ? "You" : firstName(people.get(a.personId))} ${a.text}`,
|
|
29992
|
+
mine: false,
|
|
29993
|
+
tone: "plain"
|
|
29994
|
+
}
|
|
29995
|
+
}));
|
|
29996
|
+
}
|
|
29997
|
+
/**
|
|
29998
|
+
* `--since`: "14:30" (today, on this computer's clock) or a date or moment
|
|
29999
|
+
* ("2026-10-21", "2026-10-21T14:30"), as an ISO moment; null when it isn't one.
|
|
30000
|
+
*/
|
|
30001
|
+
function watchSince(text, now) {
|
|
30002
|
+
const clock = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
|
|
30003
|
+
if (clock) {
|
|
30004
|
+
const [h, m] = [Number(clock[1]), Number(clock[2])];
|
|
30005
|
+
if (h > 23 || m > 59) return null;
|
|
30006
|
+
const at = new Date(now);
|
|
30007
|
+
at.setHours(h, m, 0, 0);
|
|
30008
|
+
return at.toISOString();
|
|
30009
|
+
}
|
|
30010
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(text.trim())) return null;
|
|
30011
|
+
const at = parseIso(text);
|
|
30012
|
+
return Number.isNaN(at.getTime()) ? null : at.toISOString();
|
|
30013
|
+
}
|
|
29487
30014
|
|
|
29488
30015
|
//#endregion
|
|
29489
30016
|
//#region src/tui/watch-run.ts
|
|
@@ -29528,7 +30055,33 @@ async function watchOn(opts) {
|
|
|
29528
30055
|
try {
|
|
29529
30056
|
const project = opts.project ? projectNamed(store.getState().projects, opts.project) : null;
|
|
29530
30057
|
const dim = (s) => opts.colour ? `${ANSI.dim}${s}${ANSI.reset}` : s;
|
|
29531
|
-
|
|
30058
|
+
const started = opts.now?.() ?? /* @__PURE__ */ new Date();
|
|
30059
|
+
const print = (line, at, earlier = false) => out(opts.json ? JSON.stringify({
|
|
30060
|
+
at: at.toISOString(),
|
|
30061
|
+
text: plain(line.text),
|
|
30062
|
+
...line.quote ? { quote: plain(line.quote) } : {},
|
|
30063
|
+
mine: line.mine,
|
|
30064
|
+
tone: line.tone,
|
|
30065
|
+
...earlier ? { earlier: true } : {}
|
|
30066
|
+
}) : watchText({
|
|
30067
|
+
...line,
|
|
30068
|
+
text: plain(line.text)
|
|
30069
|
+
}, at, {
|
|
30070
|
+
colour: opts.colour,
|
|
30071
|
+
width: opts.width?.()
|
|
30072
|
+
}));
|
|
30073
|
+
const earlier = recentLines(store.getState(), store.getState().currentUserId, {
|
|
30074
|
+
since: opts.since,
|
|
30075
|
+
projectId: project?.id ?? null
|
|
30076
|
+
});
|
|
30077
|
+
if (!opts.json) {
|
|
30078
|
+
out(dim(opts.once ? `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""}` : `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · on watch since ${wallClock(started).slice(11, 16)}${opts.bell ? " · bell on" : ""} · q to stop`));
|
|
30079
|
+
if (earlier.length === 0) out(dim(opts.since ? "Nothing changed since then." : "Nothing logged yet."));
|
|
30080
|
+
else out(dim(opts.since ? "Since then:" : "Lately:"));
|
|
30081
|
+
}
|
|
30082
|
+
for (const e of earlier) print(e.line, new Date(e.at), true);
|
|
30083
|
+
if (opts.once) return;
|
|
30084
|
+
if (!opts.json && earlier.length > 0) out(dim("New changes print below as they land."));
|
|
29532
30085
|
const narrow = (d) => project ? {
|
|
29533
30086
|
...d,
|
|
29534
30087
|
tasks: d.tasks.filter((t) => t.projectId === project.id),
|
|
@@ -29539,10 +30092,7 @@ async function watchOn(opts) {
|
|
|
29539
30092
|
if (s.tasks === prev.tasks && s.comments === prev.comments && s.projects === prev.projects && s.programs === prev.programs) return;
|
|
29540
30093
|
const lines = watchLines(narrow(prev), narrow(s), s.currentUserId);
|
|
29541
30094
|
const at = opts.now?.() ?? /* @__PURE__ */ new Date();
|
|
29542
|
-
for (const line of lines)
|
|
29543
|
-
colour: opts.colour,
|
|
29544
|
-
width: opts.width?.()
|
|
29545
|
-
}));
|
|
30095
|
+
for (const line of lines) print(line, at);
|
|
29546
30096
|
if (opts.bell && lines.some((l) => l.mine)) opts.write("\x07");
|
|
29547
30097
|
});
|
|
29548
30098
|
await opts.stopped;
|
|
@@ -29979,14 +30529,16 @@ async function fogHere(opts) {
|
|
|
29979
30529
|
if (sync.state !== "saved") throw new HarborError("Harbor didn't take those answers. Run harbor fog --sweep again.");
|
|
29980
30530
|
}
|
|
29981
30531
|
/**
|
|
29982
|
-
* `harbor watch`: the harbor's changes
|
|
29983
|
-
* or Ctrl+C
|
|
30532
|
+
* `harbor watch`: the harbor's latest changes, then each new one as it
|
|
30533
|
+
* lands, a line each, until q or Ctrl+C (or the process is stopped, when
|
|
30534
|
+
* piped). `once` prints what's there and returns. Nothing is changed from
|
|
30535
|
+
* here; it only listens.
|
|
29984
30536
|
*/
|
|
29985
30537
|
async function watchHere(opts) {
|
|
29986
30538
|
const transport = plainTransport(httpTransport(opts.source, opts.fetcher));
|
|
29987
30539
|
const answer = await transport.harbors();
|
|
29988
30540
|
const harbor = harborFor(answer.harbors, opts.harbor, opts.saved);
|
|
29989
|
-
const stopped = new Promise((resolve) => {
|
|
30541
|
+
const stopped = opts.once ? Promise.resolve() : new Promise((resolve) => {
|
|
29990
30542
|
const input = process.stdin;
|
|
29991
30543
|
const stop = () => {
|
|
29992
30544
|
input.off("data", onKey);
|
|
@@ -30011,6 +30563,9 @@ async function watchHere(opts) {
|
|
|
30011
30563
|
project: opts.project,
|
|
30012
30564
|
bell: opts.bell,
|
|
30013
30565
|
colour: opts.colour,
|
|
30566
|
+
since: opts.since,
|
|
30567
|
+
once: opts.once,
|
|
30568
|
+
json: opts.json,
|
|
30014
30569
|
width: () => process.stdout.columns,
|
|
30015
30570
|
write: (text) => process.stdout.write(text),
|
|
30016
30571
|
stopped
|
|
@@ -30057,4 +30612,4 @@ function planTransport(source, fetcher) {
|
|
|
30057
30612
|
}
|
|
30058
30613
|
|
|
30059
30614
|
//#endregion
|
|
30060
|
-
export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere, watchHere };
|
|
30615
|
+
export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere, watchHere, watchSince };
|