@harborppm/cli 0.44.0 → 0.45.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 +5 -3
- package/dist/app.mjs +1069 -67
- package/lib/cli.mjs +37 -13
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -2851,6 +2851,17 @@ var init_board = __esmMin((() => {
|
|
|
2851
2851
|
];
|
|
2852
2852
|
}));
|
|
2853
2853
|
|
|
2854
|
+
//#endregion
|
|
2855
|
+
//#region src/lib/keep.ts
|
|
2856
|
+
init_board();
|
|
2857
|
+
/** Whole days left in the trial, rounded up: 30 on the first day, 1 on the last. 0 once it's over. */
|
|
2858
|
+
function trialDaysLeft(trialEndsAt, now = /* @__PURE__ */ new Date()) {
|
|
2859
|
+
if (!trialEndsAt) return 0;
|
|
2860
|
+
const ms = Date.parse(trialEndsAt) - now.getTime();
|
|
2861
|
+
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
|
2862
|
+
return Math.ceil(ms / 864e5);
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2854
2865
|
//#endregion
|
|
2855
2866
|
//#region src/lib/cascade.ts
|
|
2856
2867
|
/** What deleting a portfolio, program, or project takes with it, and the data left behind. Pure. */
|
|
@@ -2912,7 +2923,6 @@ var init_cascade = __esmMin((() => {
|
|
|
2912
2923
|
|
|
2913
2924
|
//#endregion
|
|
2914
2925
|
//#region src/lib/forecast.ts
|
|
2915
|
-
init_board();
|
|
2916
2926
|
init_cascade();
|
|
2917
2927
|
init_date_fns();
|
|
2918
2928
|
const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
@@ -3781,6 +3791,137 @@ var init_work = __esmMin((() => {
|
|
|
3781
3791
|
escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3782
3792
|
}));
|
|
3783
3793
|
|
|
3794
|
+
//#endregion
|
|
3795
|
+
//#region src/lib/premortem.ts
|
|
3796
|
+
function premortemPhase(project, now) {
|
|
3797
|
+
const pm = project.premortem;
|
|
3798
|
+
if (!pm) return "none";
|
|
3799
|
+
return now.getTime() < Date.parse(pm.revealAt) ? "answering" : "revealed";
|
|
3800
|
+
}
|
|
3801
|
+
/** Whole seconds until the answers show; 0 once they have. */
|
|
3802
|
+
function secondsLeft(pm, now) {
|
|
3803
|
+
if (!pm) return 0;
|
|
3804
|
+
return Math.max(0, Math.ceil((Date.parse(pm.revealAt) - now.getTime()) / 1e3));
|
|
3805
|
+
}
|
|
3806
|
+
/** "4:05" */
|
|
3807
|
+
function clockText(seconds) {
|
|
3808
|
+
const s = Math.max(0, Math.round(seconds));
|
|
3809
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
3810
|
+
}
|
|
3811
|
+
/** A new pre-mortem started now: answers show together five minutes on. */
|
|
3812
|
+
function newPremortem(startedById, now) {
|
|
3813
|
+
return {
|
|
3814
|
+
startedAt: now.toISOString(),
|
|
3815
|
+
startedById,
|
|
3816
|
+
revealAt: new Date(now.getTime() + 5 * 6e4).toISOString()
|
|
3817
|
+
};
|
|
3818
|
+
}
|
|
3819
|
+
/**
|
|
3820
|
+
* The question, set just after the project's end date: "It's December.
|
|
3821
|
+
* Platform rebuild failed. What happened?" A project already past its end
|
|
3822
|
+
* date (or with none) is asked about three months on from now instead.
|
|
3823
|
+
*/
|
|
3824
|
+
function premortemQuestion(project, now) {
|
|
3825
|
+
const end = project.endDate ? parseIso(project.endDate) : null;
|
|
3826
|
+
const later = new Date(now.getFullYear(), now.getMonth() + 3, 1);
|
|
3827
|
+
const when = end && !Number.isNaN(end.getTime()) && end.getTime() > now.getTime() ? new Date(end.getFullYear(), end.getMonth() + (end.getDate() > 20 ? 1 : 0), 1) : later;
|
|
3828
|
+
return `It's ${format(when, "MMMM")}${when.getFullYear() === now.getFullYear() ? "" : ` ${when.getFullYear()}`}. ${project.name.trim() || "This project"} failed. What happened?`;
|
|
3829
|
+
}
|
|
3830
|
+
/** This project's pre-mortem answers, oldest first. */
|
|
3831
|
+
function answersOf(reflections, projectId) {
|
|
3832
|
+
return (reflections ?? []).filter((r) => r.projectId === projectId && r.kind === "premortem").sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
3833
|
+
}
|
|
3834
|
+
/** What one person may see: their own answers always; everyone's once they show. */
|
|
3835
|
+
function visibleAnswers(answers, phase, personId) {
|
|
3836
|
+
return phase === "revealed" ? [...answers] : answers.filter((a) => a.authorId === personId);
|
|
3837
|
+
}
|
|
3838
|
+
/** How the answers came out: "3 added to Risks & issues · 2 merged as duplicates". */
|
|
3839
|
+
function outcomeText(answers) {
|
|
3840
|
+
const raised = answers.filter((a) => a.raidId && !a.merged).length;
|
|
3841
|
+
const merged = answers.filter((a) => a.raidId && a.merged).length;
|
|
3842
|
+
const skipped = answers.filter((a) => a.skipped && !a.raidId).length;
|
|
3843
|
+
return [
|
|
3844
|
+
raised ? `${raised} added to Risks & issues` : "",
|
|
3845
|
+
merged ? `${merged} merged as duplicate${merged === 1 ? "" : "s"}` : "",
|
|
3846
|
+
skipped ? `${skipped} left out` : ""
|
|
3847
|
+
].filter(Boolean).join(" · ");
|
|
3848
|
+
}
|
|
3849
|
+
/**
|
|
3850
|
+
* A risk's title from an answer: its first sentence, kept under 120
|
|
3851
|
+
* characters at a word boundary, without a closing full stop.
|
|
3852
|
+
*/
|
|
3853
|
+
function riskTitle(answer) {
|
|
3854
|
+
const flat = answer.replace(/\s+/g, " ").trim();
|
|
3855
|
+
const first = /^(.+?[.!?])(\s|$)/.exec(flat)?.[1] ?? flat;
|
|
3856
|
+
let title = first.length <= 120 ? first : `${first.slice(0, 117).replace(/\s+\S*$/, "")}…`;
|
|
3857
|
+
title = title.replace(/\.$/, "");
|
|
3858
|
+
return title;
|
|
3859
|
+
}
|
|
3860
|
+
/**
|
|
3861
|
+
* The risk an answer becomes: on its project, open, unrated, with the whole
|
|
3862
|
+
* answer as what could happen. Scoped to the project alone, like a risk
|
|
3863
|
+
* logged on the project page; the program and portfolio roll it up from there.
|
|
3864
|
+
*/
|
|
3865
|
+
function riskFromAnswer(answer, project) {
|
|
3866
|
+
return {
|
|
3867
|
+
kind: "risk",
|
|
3868
|
+
title: riskTitle(answer.body),
|
|
3869
|
+
description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
|
|
3870
|
+
projectId: project.id,
|
|
3871
|
+
status: "open",
|
|
3872
|
+
ownerId: null,
|
|
3873
|
+
dueDate: null
|
|
3874
|
+
};
|
|
3875
|
+
}
|
|
3876
|
+
/** The risk's description with another answer folded in as the same worry. */
|
|
3877
|
+
function withFoldedAnswer(description, answer) {
|
|
3878
|
+
const base = description.trim();
|
|
3879
|
+
return `${base}${base ? "\n\n" : ""}Also raised: ${answer.trim()}`;
|
|
3880
|
+
}
|
|
3881
|
+
/** The words that carry an answer's meaning, crudely singular. */
|
|
3882
|
+
function contentWords(text) {
|
|
3883
|
+
const words = text.toLowerCase().replace(/[’']/g, "").split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2 && !STOP.has(w)).map((w) => w.length > 4 && w.endsWith("s") && !w.endsWith("ss") ? w.slice(0, -1) : w);
|
|
3884
|
+
return new Set(words);
|
|
3885
|
+
}
|
|
3886
|
+
/** True when two answers name the same worry: most of their meaningful words are shared. */
|
|
3887
|
+
function looksAlike(a, b) {
|
|
3888
|
+
const x = contentWords(a);
|
|
3889
|
+
const y = contentWords(b);
|
|
3890
|
+
if (x.size === 0 || y.size === 0) return false;
|
|
3891
|
+
let shared = 0;
|
|
3892
|
+
for (const w of x) if (y.has(w)) shared += 1;
|
|
3893
|
+
if (shared < 2) return false;
|
|
3894
|
+
return shared / Math.min(x.size, y.size) >= .5;
|
|
3895
|
+
}
|
|
3896
|
+
/**
|
|
3897
|
+
* The risk an answer probably repeats: one raised from an earlier answer in
|
|
3898
|
+
* this pre-mortem whose words it mostly shares. Null when none does.
|
|
3899
|
+
*/
|
|
3900
|
+
function likelySame(answer, answers, risks) {
|
|
3901
|
+
const raised = new Map(risks.map((r) => [r.id, r]));
|
|
3902
|
+
for (const other of answers) {
|
|
3903
|
+
if (other.id === answer.id || !other.raidId || other.merged) continue;
|
|
3904
|
+
const risk = raised.get(other.raidId);
|
|
3905
|
+
if (risk && looksAlike(answer.body, other.body)) return risk;
|
|
3906
|
+
}
|
|
3907
|
+
return null;
|
|
3908
|
+
}
|
|
3909
|
+
/** Whether a project still looks new enough for the pre-mortem to be the obvious next step. */
|
|
3910
|
+
function invitesPremortem(project, openRisks, today) {
|
|
3911
|
+
if (project.premortem || project.health === "done") return false;
|
|
3912
|
+
if (openRisks > 0) return false;
|
|
3913
|
+
const start = project.startDate?.slice(0, 10) ?? today;
|
|
3914
|
+
const started = parseIso(start);
|
|
3915
|
+
return (parseIso(today).getTime() - started.getTime()) / 864e5 <= 30;
|
|
3916
|
+
}
|
|
3917
|
+
var PREMORTEM_MINUTES, isPending, STOP;
|
|
3918
|
+
var init_premortem = __esmMin((() => {
|
|
3919
|
+
init_date_text();
|
|
3920
|
+
PREMORTEM_MINUTES = 5;
|
|
3921
|
+
isPending = (a) => !a.raidId && !a.skipped;
|
|
3922
|
+
STOP = new Set("the a an and or but of to in on for with at by from into onto was were be been being is are it its this that these those we our us they them their he she his her i my me you your had has have did do does not no so too very just than then there here what which who because about after before over under again more most some any all each other only own same can could would should will got get project failed happened went".split(" "));
|
|
3923
|
+
}));
|
|
3924
|
+
|
|
3784
3925
|
//#endregion
|
|
3785
3926
|
//#region src/tui/model.ts
|
|
3786
3927
|
init_date_fns();
|
|
@@ -3792,6 +3933,7 @@ init_search();
|
|
|
3792
3933
|
init_spend();
|
|
3793
3934
|
init_task_keys();
|
|
3794
3935
|
init_work();
|
|
3936
|
+
init_premortem();
|
|
3795
3937
|
const STATUS_LABEL = {
|
|
3796
3938
|
backlog: "Backlog",
|
|
3797
3939
|
todo: "To do",
|
|
@@ -4177,6 +4319,214 @@ function markdownToPlain(md) {
|
|
|
4177
4319
|
function healthMoves(history, today, max = 3) {
|
|
4178
4320
|
return [...history ?? []].slice(-max).reverse().map((h) => `${shortDay(h.at.slice(0, 10), today).padEnd(7)} ${HEALTH_LABEL[h.health]}${h.override ? ` (set by hand${h.note ? `: ${h.note}` : ""})` : ""}`);
|
|
4179
4321
|
}
|
|
4322
|
+
/**
|
|
4323
|
+
* A project's pre-mortem for the terminal: whether one runs, the question,
|
|
4324
|
+
* the time left while answers are private, the answers this person may see
|
|
4325
|
+
* and how they came out. The website's PremortemCard, as lines.
|
|
4326
|
+
*/
|
|
4327
|
+
function premortemOf(data, project, me, now) {
|
|
4328
|
+
const phase = premortemPhase(project, now);
|
|
4329
|
+
if (phase === "none") return {
|
|
4330
|
+
phase,
|
|
4331
|
+
question: null,
|
|
4332
|
+
left: 0,
|
|
4333
|
+
rows: [],
|
|
4334
|
+
outcome: "",
|
|
4335
|
+
total: 0
|
|
4336
|
+
};
|
|
4337
|
+
const answers = answersOf(data.reflections, project.id);
|
|
4338
|
+
const risks = data.raidItems.filter((r) => r.projectId === project.id);
|
|
4339
|
+
const raised = byId(risks);
|
|
4340
|
+
const rows = visibleAnswers(answers, phase, me).map((a) => {
|
|
4341
|
+
const risk = a.raidId ? raised.get(a.raidId) ?? null : null;
|
|
4342
|
+
return {
|
|
4343
|
+
answer: a,
|
|
4344
|
+
mine: a.authorId === me,
|
|
4345
|
+
tag: a.raidId ? a.merged ? "Merged" : "Risk" : a.skipped ? "Left out" : null,
|
|
4346
|
+
risk,
|
|
4347
|
+
same: phase === "revealed" && isPending(a) ? likelySame(a, answers, risks) : null
|
|
4348
|
+
};
|
|
4349
|
+
});
|
|
4350
|
+
return {
|
|
4351
|
+
phase,
|
|
4352
|
+
question: premortemQuestion(project, new Date(project.premortem.startedAt)),
|
|
4353
|
+
left: secondsLeft(project.premortem, now),
|
|
4354
|
+
rows,
|
|
4355
|
+
outcome: phase === "revealed" ? outcomeText(answers) : "",
|
|
4356
|
+
total: answers.length
|
|
4357
|
+
};
|
|
4358
|
+
}
|
|
4359
|
+
/** The pre-mortem's state in a few words for the project's header, or null when there's nothing to say. */
|
|
4360
|
+
function premortemNote(data, project, now, today) {
|
|
4361
|
+
const phase = premortemPhase(project, now);
|
|
4362
|
+
if (phase === "none") {
|
|
4363
|
+
const open = data.raidItems.filter((r) => r.projectId === project.id && isActive(r)).length;
|
|
4364
|
+
return invitesPremortem(project, open, today) ? "P runs a pre-mortem" : null;
|
|
4365
|
+
}
|
|
4366
|
+
if (phase === "answering") return `pre-mortem: answers show in ${clockText(secondsLeft(project.premortem, now))} · P`;
|
|
4367
|
+
const pending = answersOf(data.reflections, project.id).filter(isPending).length;
|
|
4368
|
+
return pending ? `pre-mortem: ${pending} to sort · P` : "pre-mortem · P";
|
|
4369
|
+
}
|
|
4370
|
+
const ROLE_LABEL = {
|
|
4371
|
+
owner: "Owner",
|
|
4372
|
+
member: "Can edit",
|
|
4373
|
+
viewer: "View only"
|
|
4374
|
+
};
|
|
4375
|
+
/** The address without its scheme: harborppm.com/share/… */
|
|
4376
|
+
const bare = (url) => url.replace(/^https?:\/\//, "");
|
|
4377
|
+
/**
|
|
4378
|
+
* Everything the Crew screen lists, in the website's words: who's aboard,
|
|
4379
|
+
* and for the owner the letters still out, share links with who gets their
|
|
4380
|
+
* Monday update, and intake forms; then the person's own connected apps,
|
|
4381
|
+
* email choices, and how the harbor is kept.
|
|
4382
|
+
*/
|
|
4383
|
+
function crewRows(crew, data, now) {
|
|
4384
|
+
const owner = crew.harbor.role === "owner";
|
|
4385
|
+
const rows = [];
|
|
4386
|
+
const nameWidth = Math.min(24, Math.max(0, ...crew.members.map((m) => (m.name ?? "").length)));
|
|
4387
|
+
rows.push({
|
|
4388
|
+
kind: "heading",
|
|
4389
|
+
text: `Aboard · ${crew.members.length}`
|
|
4390
|
+
});
|
|
4391
|
+
for (const m of crew.members) rows.push({
|
|
4392
|
+
kind: "member",
|
|
4393
|
+
text: (m.name || m.email || "Someone").padEnd(nameWidth),
|
|
4394
|
+
detail: [
|
|
4395
|
+
ROLE_LABEL[m.role] ?? m.role,
|
|
4396
|
+
m.email,
|
|
4397
|
+
m.userId === crew.you ? "you" : null
|
|
4398
|
+
].filter(Boolean).join(" · "),
|
|
4399
|
+
userId: m.userId,
|
|
4400
|
+
role: m.role,
|
|
4401
|
+
changeable: owner && m.role !== "owner"
|
|
4402
|
+
});
|
|
4403
|
+
if (!owner) {
|
|
4404
|
+
const by = crew.members.find((m) => m.role === "owner");
|
|
4405
|
+
rows.push({
|
|
4406
|
+
kind: "note",
|
|
4407
|
+
text: `${by?.name || "The owner"} invites people and makes share links and intake forms.`
|
|
4408
|
+
});
|
|
4409
|
+
} else {
|
|
4410
|
+
rows.push({
|
|
4411
|
+
kind: "heading",
|
|
4412
|
+
text: "Letters still out"
|
|
4413
|
+
});
|
|
4414
|
+
if (!crew.invites.length) rows.push({
|
|
4415
|
+
kind: "note",
|
|
4416
|
+
text: "None. i invites someone by email."
|
|
4417
|
+
});
|
|
4418
|
+
for (const i of crew.invites) rows.push({
|
|
4419
|
+
kind: "invite",
|
|
4420
|
+
text: i.email || i.name || "A link for anyone",
|
|
4421
|
+
detail: `${ROLE_LABEL[i.role]} · asked ${timeAgo(new Date(i.createdAt), now)}`,
|
|
4422
|
+
token: i.token,
|
|
4423
|
+
url: i.url
|
|
4424
|
+
});
|
|
4425
|
+
rows.push({
|
|
4426
|
+
kind: "heading",
|
|
4427
|
+
text: "Share links: a portfolio, read-only, for anyone with the link"
|
|
4428
|
+
});
|
|
4429
|
+
if (!crew.shares.length) rows.push({
|
|
4430
|
+
kind: "note",
|
|
4431
|
+
text: "None. s makes one for a portfolio."
|
|
4432
|
+
});
|
|
4433
|
+
const portfolios = byId(data.portfolios);
|
|
4434
|
+
for (const s of crew.shares) {
|
|
4435
|
+
const getting = s.subscribers.filter((x) => !x.stopped).length;
|
|
4436
|
+
rows.push({
|
|
4437
|
+
kind: "share",
|
|
4438
|
+
text: portfolios.get(s.portfolioId)?.name ?? "A portfolio no longer here",
|
|
4439
|
+
detail: `${bare(s.url)} · ${getting ? `${getting} get the Monday update` : "no Monday update yet"}`,
|
|
4440
|
+
token: s.token,
|
|
4441
|
+
url: s.url
|
|
4442
|
+
});
|
|
4443
|
+
for (const x of s.subscribers) rows.push({
|
|
4444
|
+
kind: "subscriber",
|
|
4445
|
+
text: ` ${x.email}`,
|
|
4446
|
+
detail: x.stopped ? "stopped it themselves" : "gets the Monday update",
|
|
4447
|
+
token: s.token,
|
|
4448
|
+
email: x.email,
|
|
4449
|
+
stopped: x.stopped
|
|
4450
|
+
});
|
|
4451
|
+
}
|
|
4452
|
+
rows.push({
|
|
4453
|
+
kind: "heading",
|
|
4454
|
+
text: "Intake forms: anyone with the link adds a request to a project"
|
|
4455
|
+
});
|
|
4456
|
+
if (!crew.forms.length) rows.push({
|
|
4457
|
+
kind: "note",
|
|
4458
|
+
text: "None. f makes one for a project."
|
|
4459
|
+
});
|
|
4460
|
+
const projects = byId(data.projects);
|
|
4461
|
+
for (const f of crew.forms) rows.push({
|
|
4462
|
+
kind: "form",
|
|
4463
|
+
text: projects.get(f.projectId)?.name ?? "A project no longer here",
|
|
4464
|
+
detail: bare(f.url),
|
|
4465
|
+
token: f.token,
|
|
4466
|
+
url: f.url
|
|
4467
|
+
});
|
|
4468
|
+
}
|
|
4469
|
+
rows.push({
|
|
4470
|
+
kind: "heading",
|
|
4471
|
+
text: "Apps you connected"
|
|
4472
|
+
});
|
|
4473
|
+
if (!crew.apps.length) rows.push({
|
|
4474
|
+
kind: "note",
|
|
4475
|
+
text: "None."
|
|
4476
|
+
});
|
|
4477
|
+
for (const a of crew.apps) rows.push({
|
|
4478
|
+
kind: "app",
|
|
4479
|
+
text: a.name,
|
|
4480
|
+
detail: [
|
|
4481
|
+
a.fullAccess ? "full access" : a.canWrite ? "reads and makes changes" : "reads only",
|
|
4482
|
+
a.returnsTo,
|
|
4483
|
+
a.active ? null : "signed out",
|
|
4484
|
+
a.thisApp ? "this app" : null
|
|
4485
|
+
].filter(Boolean).join(" · "),
|
|
4486
|
+
clientId: a.clientId,
|
|
4487
|
+
name: a.name,
|
|
4488
|
+
thisApp: a.thisApp
|
|
4489
|
+
});
|
|
4490
|
+
rows.push({
|
|
4491
|
+
kind: "heading",
|
|
4492
|
+
text: "Your email"
|
|
4493
|
+
});
|
|
4494
|
+
rows.push({
|
|
4495
|
+
kind: "email",
|
|
4496
|
+
text: "Monday digest of the week ahead",
|
|
4497
|
+
detail: crew.email.weeklyDigest ? "on" : "off",
|
|
4498
|
+
which: "weeklyDigest",
|
|
4499
|
+
on: crew.email.weeklyDigest
|
|
4500
|
+
});
|
|
4501
|
+
rows.push({
|
|
4502
|
+
kind: "email",
|
|
4503
|
+
text: "When a task is assigned to you",
|
|
4504
|
+
detail: crew.email.assignments ? "on" : "off",
|
|
4505
|
+
which: "assignments",
|
|
4506
|
+
on: crew.email.assignments
|
|
4507
|
+
});
|
|
4508
|
+
rows.push({
|
|
4509
|
+
kind: "heading",
|
|
4510
|
+
text: "Keep"
|
|
4511
|
+
});
|
|
4512
|
+
rows.push({
|
|
4513
|
+
kind: "keep",
|
|
4514
|
+
text: keepText(crew.keep, now),
|
|
4515
|
+
detail: bare(crew.keepUrl),
|
|
4516
|
+
url: crew.keepUrl
|
|
4517
|
+
});
|
|
4518
|
+
return rows;
|
|
4519
|
+
}
|
|
4520
|
+
/** How the harbor is kept, in a few words. */
|
|
4521
|
+
function keepText(keep, now) {
|
|
4522
|
+
if (keep.kept) return `Kept, paid ${keep.interval === "year" ? "yearly" : "monthly"}`;
|
|
4523
|
+
if (keep.paused) return "The free trial has ended: view only until someone keeps it";
|
|
4524
|
+
if (keep.trialEndsAt) {
|
|
4525
|
+
const days = trialDaysLeft(keep.trialEndsAt, now);
|
|
4526
|
+
return `Free trial, ${days} day${days === 1 ? "" : "s"} left`;
|
|
4527
|
+
}
|
|
4528
|
+
return "Free";
|
|
4529
|
+
}
|
|
4180
4530
|
|
|
4181
4531
|
//#endregion
|
|
4182
4532
|
//#region src/tui/theme.ts
|
|
@@ -6003,6 +6353,289 @@ function CalendarView({ active, width, height, go }) {
|
|
|
6003
6353
|
});
|
|
6004
6354
|
}
|
|
6005
6355
|
|
|
6356
|
+
//#endregion
|
|
6357
|
+
//#region src/tui/views/crew.tsx
|
|
6358
|
+
/**
|
|
6359
|
+
* The Crew screen: who's aboard and what they can do, and for the owner the
|
|
6360
|
+
* letters still out, share links (with their Monday update) and intake
|
|
6361
|
+
* forms; then your own connected apps, email choices and how the harbor is
|
|
6362
|
+
* kept. None of it is in the harbor's document, so it's asked of Harbor on
|
|
6363
|
+
* opening and after each change (mcp/app.server.ts answerCrew).
|
|
6364
|
+
*/
|
|
6365
|
+
init_clock();
|
|
6366
|
+
const actionable = (r) => r.kind !== "heading" && r.kind !== "note";
|
|
6367
|
+
/** What the chosen line's keys do, for the line along the bottom. */
|
|
6368
|
+
function rowHints(r, owner) {
|
|
6369
|
+
if (!r) return [];
|
|
6370
|
+
switch (r.kind) {
|
|
6371
|
+
case "member": return r.changeable ? ["Enter change role"] : [];
|
|
6372
|
+
case "invite":
|
|
6373
|
+
case "form": return ["y copy link", "w withdraw"];
|
|
6374
|
+
case "share": return [
|
|
6375
|
+
"y copy link",
|
|
6376
|
+
"m add to the Monday update",
|
|
6377
|
+
"w withdraw"
|
|
6378
|
+
];
|
|
6379
|
+
case "subscriber": return r.stopped ? [] : ["w take off the update"];
|
|
6380
|
+
case "app": return r.thisApp ? [] : ["d disconnect"];
|
|
6381
|
+
case "email": return [`Enter turn ${r.on ? "off" : "on"}`];
|
|
6382
|
+
case "keep": return [owner ? "Enter open Keep in the browser" : "Enter open in the browser"];
|
|
6383
|
+
default: return [];
|
|
6384
|
+
}
|
|
6385
|
+
}
|
|
6386
|
+
function CrewView({ active, width, height, go }) {
|
|
6387
|
+
const { transport, browse } = useDeps();
|
|
6388
|
+
const { stdout } = useStdout();
|
|
6389
|
+
const workspaceId = useHarbor((s) => s.workspaceId);
|
|
6390
|
+
const portfolios = useHarbor((s) => s.portfolios);
|
|
6391
|
+
const projects = useHarbor((s) => s.projects);
|
|
6392
|
+
const [crew, setCrew] = useState(null);
|
|
6393
|
+
const [error, setError] = useState(null);
|
|
6394
|
+
const [busy, setBusy] = useState(false);
|
|
6395
|
+
const [at, setAt] = useState(0);
|
|
6396
|
+
useEffect(() => {
|
|
6397
|
+
if (!workspaceId) return;
|
|
6398
|
+
let live = true;
|
|
6399
|
+
setError(null);
|
|
6400
|
+
transport.crew(workspaceId).then((c) => live && setCrew(c), (e) => live && setError(e.message));
|
|
6401
|
+
return () => {
|
|
6402
|
+
live = false;
|
|
6403
|
+
};
|
|
6404
|
+
}, [transport, workspaceId]);
|
|
6405
|
+
const rows = useMemo(() => crew ? crewRows(crew, {
|
|
6406
|
+
portfolios,
|
|
6407
|
+
projects
|
|
6408
|
+
}, clockNow()) : [], [
|
|
6409
|
+
crew,
|
|
6410
|
+
portfolios,
|
|
6411
|
+
projects
|
|
6412
|
+
]);
|
|
6413
|
+
const picks = rows.map((r, i) => actionable(r) ? i : -1).filter((i) => i >= 0);
|
|
6414
|
+
const chosen = picks.length ? picks[Math.min(at, picks.length - 1)] : -1;
|
|
6415
|
+
const row = chosen >= 0 ? rows[chosen] : void 0;
|
|
6416
|
+
const owner = crew?.harbor.role === "owner";
|
|
6417
|
+
/** One change, then the screen as Harbor now has it; `said` words the notice. */
|
|
6418
|
+
const run = (act, said) => {
|
|
6419
|
+
if (!workspaceId || busy) return;
|
|
6420
|
+
setBusy(true);
|
|
6421
|
+
transport.crew(workspaceId, act).then((c) => {
|
|
6422
|
+
setCrew(c);
|
|
6423
|
+
const words = said?.(c);
|
|
6424
|
+
if (words) go.notify(words);
|
|
6425
|
+
}).catch((e) => go.notify(e.message)).finally(() => setBusy(false));
|
|
6426
|
+
};
|
|
6427
|
+
const copy = (url, what) => {
|
|
6428
|
+
stdout.write(copySequence(url));
|
|
6429
|
+
go.notify(`Copied the ${what}, if this terminal lets apps copy: ${url}`);
|
|
6430
|
+
};
|
|
6431
|
+
const open = (url) => {
|
|
6432
|
+
if (browse) {
|
|
6433
|
+
browse(url);
|
|
6434
|
+
go.notify(`Opening ${url.replace(/^https?:\/\//, "")} in your browser.`);
|
|
6435
|
+
} else go.notify(`Open ${url} in a browser.`);
|
|
6436
|
+
};
|
|
6437
|
+
const invite = () => go.ask("Invite by email", (address) => {
|
|
6438
|
+
const email = address.trim();
|
|
6439
|
+
if (!email || !workspaceId) return;
|
|
6440
|
+
go.pick(`What can ${email} do?`, [{
|
|
6441
|
+
label: "Can edit",
|
|
6442
|
+
value: "member",
|
|
6443
|
+
hint: "change plans, move work"
|
|
6444
|
+
}, {
|
|
6445
|
+
label: "View only",
|
|
6446
|
+
value: "viewer",
|
|
6447
|
+
hint: "read and comment"
|
|
6448
|
+
}], (role) => {
|
|
6449
|
+
setBusy(true);
|
|
6450
|
+
transport.invite(workspaceId, email, role).then(async (made) => {
|
|
6451
|
+
if (made.link) {
|
|
6452
|
+
stdout.write(copySequence(made.link));
|
|
6453
|
+
go.notify(`Your own address isn't confirmed yet, so the letter wasn't mailed. Its link is copied: ${made.link}`);
|
|
6454
|
+
} else go.notify(`${made.again ? "Asked again" : "Invited"} ${made.email}. The letter is on its way${made.again ? "; the earlier link stopped working" : ""}.`);
|
|
6455
|
+
setCrew(await transport.crew(workspaceId));
|
|
6456
|
+
}).catch((e) => go.notify(e.message)).finally(() => setBusy(false));
|
|
6457
|
+
});
|
|
6458
|
+
});
|
|
6459
|
+
useInput((input, key) => {
|
|
6460
|
+
if (key.upArrow || input === "k") return setAt((i) => Math.max(0, i - 1));
|
|
6461
|
+
if (key.downArrow || input === "j") return setAt((i) => Math.min(picks.length - 1, i + 1));
|
|
6462
|
+
if (key.pageDown) return setAt((i) => Math.min(picks.length - 1, i + 10));
|
|
6463
|
+
if (key.pageUp) return setAt((i) => Math.max(0, i - 10));
|
|
6464
|
+
if (!crew) return;
|
|
6465
|
+
if (owner && input === "i") return invite();
|
|
6466
|
+
if (owner && input === "s") {
|
|
6467
|
+
if (!portfolios.length) return go.notify("There's no portfolio to share yet.");
|
|
6468
|
+
return go.pick("Share which portfolio, read-only?", portfolios.map((p) => ({
|
|
6469
|
+
label: p.name,
|
|
6470
|
+
value: p.id
|
|
6471
|
+
})), (id) => run({
|
|
6472
|
+
act: "share",
|
|
6473
|
+
portfolioId: id
|
|
6474
|
+
}, (c) => {
|
|
6475
|
+
const url = c.url;
|
|
6476
|
+
if (url) stdout.write(copySequence(url));
|
|
6477
|
+
return url ? `Made a share link and copied it: ${url}` : null;
|
|
6478
|
+
}));
|
|
6479
|
+
}
|
|
6480
|
+
if (owner && input === "f") {
|
|
6481
|
+
if (!projects.length) return go.notify("There's no project for a form yet.");
|
|
6482
|
+
return go.pick("A form that adds requests to which project?", projects.map((p) => ({
|
|
6483
|
+
label: p.name,
|
|
6484
|
+
value: p.id
|
|
6485
|
+
})), (id) => run({
|
|
6486
|
+
act: "intake",
|
|
6487
|
+
projectId: id
|
|
6488
|
+
}, (c) => {
|
|
6489
|
+
const url = c.url;
|
|
6490
|
+
if (url) stdout.write(copySequence(url));
|
|
6491
|
+
return url ? `Made an intake form and copied its link: ${url}` : null;
|
|
6492
|
+
}));
|
|
6493
|
+
}
|
|
6494
|
+
if (!row) return;
|
|
6495
|
+
if (key.return) {
|
|
6496
|
+
if (row.kind === "member" && row.changeable) return go.pick(`What can ${row.text.trim()} do?`, [{
|
|
6497
|
+
label: "Can edit",
|
|
6498
|
+
value: "member",
|
|
6499
|
+
hint: "change plans, move work"
|
|
6500
|
+
}, {
|
|
6501
|
+
label: "View only",
|
|
6502
|
+
value: "viewer",
|
|
6503
|
+
hint: "read and comment"
|
|
6504
|
+
}], (role) => run({
|
|
6505
|
+
act: "role",
|
|
6506
|
+
userId: row.userId,
|
|
6507
|
+
role
|
|
6508
|
+
}, () => `${row.text.trim()}: ${ROLE_LABEL[role]}.`));
|
|
6509
|
+
if (row.kind === "email") return run({
|
|
6510
|
+
act: "email",
|
|
6511
|
+
[row.which]: !row.on
|
|
6512
|
+
});
|
|
6513
|
+
if (row.kind === "keep") return open(row.url);
|
|
6514
|
+
if (row.kind === "invite" || row.kind === "share" || row.kind === "form") return copy(row.url, "link");
|
|
6515
|
+
return;
|
|
6516
|
+
}
|
|
6517
|
+
if (input === "y" && (row.kind === "invite" || row.kind === "share" || row.kind === "form")) return copy(row.url, "link");
|
|
6518
|
+
if (input === "o" && row.kind === "keep") return open(row.url);
|
|
6519
|
+
if (input === "m" && row.kind === "share") return go.ask("Who gets the Monday update? (addresses, commas between)", (text) => run({
|
|
6520
|
+
act: "subscribe",
|
|
6521
|
+
token: row.token,
|
|
6522
|
+
emails: text
|
|
6523
|
+
}, (c) => {
|
|
6524
|
+
const r = c;
|
|
6525
|
+
if (r.needsVerifiedEmail) return "Confirm your own address first (the letter in your inbox), then add people.";
|
|
6526
|
+
return [
|
|
6527
|
+
r.added?.length ? `Added ${r.added.join(", ")}.` : "Nobody added.",
|
|
6528
|
+
r.alreadyThere ? `${r.alreadyThere} already get it.` : "",
|
|
6529
|
+
r.invalid?.length ? `Not addresses: ${r.invalid.join(", ")}.` : "",
|
|
6530
|
+
r.overLimit ? `${r.overLimit} over the limit of 25.` : ""
|
|
6531
|
+
].filter(Boolean).join(" ");
|
|
6532
|
+
}));
|
|
6533
|
+
if (input === "w") {
|
|
6534
|
+
if (row.kind === "invite") return go.confirm(`Withdraw the letter to ${row.text}? Its link stops working.`, () => run({
|
|
6535
|
+
act: "withdraw-invite",
|
|
6536
|
+
token: row.token
|
|
6537
|
+
}, () => "Withdrawn."), "Withdraw");
|
|
6538
|
+
if (row.kind === "share") return go.confirm(`Turn off the share link to ${row.text}? Anyone holding it loses the page, and its Monday update stops.`, () => run({
|
|
6539
|
+
act: "withdraw-share",
|
|
6540
|
+
token: row.token
|
|
6541
|
+
}, () => "Turned off."), "Turn it off");
|
|
6542
|
+
if (row.kind === "form") return go.confirm(`Turn off the intake form for ${row.text}? Its link stops taking requests.`, () => run({
|
|
6543
|
+
act: "withdraw-intake",
|
|
6544
|
+
token: row.token
|
|
6545
|
+
}, () => "Turned off."), "Turn it off");
|
|
6546
|
+
if (row.kind === "subscriber" && !row.stopped) return go.confirm(`Take ${row.email} off the Monday update?`, () => run({
|
|
6547
|
+
act: "unsubscribe",
|
|
6548
|
+
token: row.token,
|
|
6549
|
+
email: row.email
|
|
6550
|
+
}, () => `${row.email} won't get it any more.`), "Take them off");
|
|
6551
|
+
}
|
|
6552
|
+
if (input === "d" && row.kind === "app") {
|
|
6553
|
+
if (row.thisApp) return go.notify("That's this app. harbor logout disconnects it from here.");
|
|
6554
|
+
return go.confirm(`Disconnect ${row.name}? It stops reading Harbor as you until you connect it again.`, () => run({
|
|
6555
|
+
act: "disconnect",
|
|
6556
|
+
clientId: row.clientId
|
|
6557
|
+
}, () => `Disconnected ${row.name}.`), "Disconnect");
|
|
6558
|
+
}
|
|
6559
|
+
}, { isActive: active });
|
|
6560
|
+
if (error) return /* @__PURE__ */ jsxs(Box, {
|
|
6561
|
+
flexDirection: "column",
|
|
6562
|
+
width,
|
|
6563
|
+
height,
|
|
6564
|
+
paddingX: 1,
|
|
6565
|
+
children: [/* @__PURE__ */ jsx(Heading, { children: "Crew" }), /* @__PURE__ */ jsx(Text, {
|
|
6566
|
+
color: theme.muted,
|
|
6567
|
+
wrap: "wrap",
|
|
6568
|
+
children: error
|
|
6569
|
+
})]
|
|
6570
|
+
});
|
|
6571
|
+
if (!crew) return /* @__PURE__ */ jsxs(Box, {
|
|
6572
|
+
flexDirection: "column",
|
|
6573
|
+
width,
|
|
6574
|
+
height,
|
|
6575
|
+
paddingX: 1,
|
|
6576
|
+
children: [/* @__PURE__ */ jsx(Heading, { children: "Crew" }), /* @__PURE__ */ jsx(Text, {
|
|
6577
|
+
color: theme.muted,
|
|
6578
|
+
children: "Asking Harbor…"
|
|
6579
|
+
})]
|
|
6580
|
+
});
|
|
6581
|
+
const hints = [...rowHints(row, owner), ...owner ? [
|
|
6582
|
+
"i invite",
|
|
6583
|
+
"s share link",
|
|
6584
|
+
"f intake form"
|
|
6585
|
+
] : []];
|
|
6586
|
+
const view = windowOf(rows, Math.max(0, chosen), Math.max(3, height - 3));
|
|
6587
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
6588
|
+
flexDirection: "column",
|
|
6589
|
+
width,
|
|
6590
|
+
height,
|
|
6591
|
+
paddingX: 1,
|
|
6592
|
+
children: [
|
|
6593
|
+
/* @__PURE__ */ jsx(Heading, {
|
|
6594
|
+
right: owner ? "you're the owner" : `you ${crew.harbor.role === "viewer" ? "can view" : "can edit"}`,
|
|
6595
|
+
children: "Crew"
|
|
6596
|
+
}),
|
|
6597
|
+
view.items.map((r, n) => {
|
|
6598
|
+
const i = view.start + n;
|
|
6599
|
+
if (r.kind === "heading") return /* @__PURE__ */ jsx(Text, {
|
|
6600
|
+
bold: true,
|
|
6601
|
+
wrap: "truncate-end",
|
|
6602
|
+
children: r.text
|
|
6603
|
+
}, i);
|
|
6604
|
+
if (r.kind === "note") return /* @__PURE__ */ jsxs(Text, {
|
|
6605
|
+
color: theme.muted,
|
|
6606
|
+
wrap: "truncate-end",
|
|
6607
|
+
children: [" ", r.text]
|
|
6608
|
+
}, i);
|
|
6609
|
+
const on = active && i === chosen;
|
|
6610
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
6611
|
+
color: on ? theme.selected : void 0,
|
|
6612
|
+
bold: on,
|
|
6613
|
+
wrap: "truncate-end",
|
|
6614
|
+
children: [
|
|
6615
|
+
on ? "› " : " ",
|
|
6616
|
+
r.text,
|
|
6617
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
6618
|
+
color: theme.muted,
|
|
6619
|
+
children: [" ", r.detail]
|
|
6620
|
+
})
|
|
6621
|
+
]
|
|
6622
|
+
}, i);
|
|
6623
|
+
}),
|
|
6624
|
+
/* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
|
|
6625
|
+
crew.pretend ? /* @__PURE__ */ jsx(Text, {
|
|
6626
|
+
color: theme.muted,
|
|
6627
|
+
wrap: "truncate-end",
|
|
6628
|
+
children: "This crew is pretend: nothing here is mailed or kept."
|
|
6629
|
+
}) : null,
|
|
6630
|
+
/* @__PURE__ */ jsx(Text, {
|
|
6631
|
+
color: theme.muted,
|
|
6632
|
+
wrap: "truncate-end",
|
|
6633
|
+
children: busy ? "Asking Harbor…" : hints.join(" · ")
|
|
6634
|
+
})
|
|
6635
|
+
]
|
|
6636
|
+
});
|
|
6637
|
+
}
|
|
6638
|
+
|
|
6006
6639
|
//#endregion
|
|
6007
6640
|
//#region src/lib/hill.ts
|
|
6008
6641
|
/** A position kept on the scale, in whole steps. */
|
|
@@ -8729,7 +9362,7 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
|
|
|
8729
9362
|
const HELP = [
|
|
8730
9363
|
["0–9 / Tab", "the screens / between the sidebar and a screen"],
|
|
8731
9364
|
[":", "anything: a screen, something new, a name"],
|
|
8732
|
-
["/ / H", "find a task
|
|
9365
|
+
["/ / H / C", "find a task / switch harbor / the crew"],
|
|
8733
9366
|
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
8734
9367
|
["Enter / Esc", "open / back"],
|
|
8735
9368
|
["n", "new: a task, or what the screen lists"],
|
|
@@ -8738,7 +9371,7 @@ const HELP = [
|
|
|
8738
9371
|
["x / d", "mark done / delete (asks first)"],
|
|
8739
9372
|
["u / r", "undo your last change / retry saving"],
|
|
8740
9373
|
["v", "switch layout: board, list, budget, by project"],
|
|
8741
|
-
["m / R", "
|
|
9374
|
+
["m / R / P", "a milestone / a risk / a project's pre-mortem"],
|
|
8742
9375
|
["D", "show or hide Done projects and programs"],
|
|
8743
9376
|
["c / a / C", "on a task: comment / add a step / change yours"],
|
|
8744
9377
|
["f / c", "on Today: focus on a task / check in"],
|
|
@@ -8906,6 +9539,195 @@ function Palette({ width, base, open, onClose }) {
|
|
|
8906
9539
|
});
|
|
8907
9540
|
}
|
|
8908
9541
|
|
|
9542
|
+
//#endregion
|
|
9543
|
+
//#region src/tui/views/premortem.tsx
|
|
9544
|
+
/**
|
|
9545
|
+
* A project's pre-mortem in the terminal, as the website's card runs it:
|
|
9546
|
+
* start one, answer alone while the clock runs, then turn each answer into
|
|
9547
|
+
* a risk, fold it into one already raised, or leave it out.
|
|
9548
|
+
*/
|
|
9549
|
+
init_clock();
|
|
9550
|
+
init_premortem();
|
|
9551
|
+
const cut = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
9552
|
+
function PremortemPanel({ id, width, height, go, active, onClose }) {
|
|
9553
|
+
const { store, today } = useDeps();
|
|
9554
|
+
const project = useHarbor((s) => s.projects.find((p) => p.id === id));
|
|
9555
|
+
const reflections = useHarbor((s) => s.reflections);
|
|
9556
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
9557
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
9558
|
+
const viewOnly = useHarbor((s) => s.myRole === "viewer" || Boolean(s.keepPaused));
|
|
9559
|
+
const [at, setAt] = useState(0);
|
|
9560
|
+
const [, setBeat] = useState(0);
|
|
9561
|
+
const pm = project ? premortemOf({
|
|
9562
|
+
reflections,
|
|
9563
|
+
raidItems
|
|
9564
|
+
}, project, me, clockNow()) : null;
|
|
9565
|
+
const ticking = pm?.phase === "answering";
|
|
9566
|
+
useEffect(() => {
|
|
9567
|
+
if (!ticking) return;
|
|
9568
|
+
const t = setInterval(() => setBeat((n) => n + 1), 1e3);
|
|
9569
|
+
return () => clearInterval(t);
|
|
9570
|
+
}, [ticking]);
|
|
9571
|
+
const rows = pm?.rows ?? [];
|
|
9572
|
+
const row = rows[Math.min(at, rows.length - 1)];
|
|
9573
|
+
const s = () => store.getState();
|
|
9574
|
+
const answer = () => go.ask("Your answer: what went wrong?", (text) => {
|
|
9575
|
+
if (text.trim() && s().answerPremortem(id, text)) {
|
|
9576
|
+
setAt(rows.length);
|
|
9577
|
+
go.notify(pm?.phase === "answering" ? "Added. Nobody sees it until the time is up." : "Added.");
|
|
9578
|
+
}
|
|
9579
|
+
});
|
|
9580
|
+
useInput((input, key) => {
|
|
9581
|
+
if (key.escape) return onClose();
|
|
9582
|
+
if (!project || !pm) return;
|
|
9583
|
+
if (key.upArrow || input === "k") return setAt((i) => step(i, -1, rows.length));
|
|
9584
|
+
if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows.length));
|
|
9585
|
+
if (key.return && row?.risk) return go.openPanel("raid", row.risk.id);
|
|
9586
|
+
if (viewOnly) return;
|
|
9587
|
+
if (pm.phase === "none") {
|
|
9588
|
+
if (input === "s") s().startPremortem(id);
|
|
9589
|
+
return;
|
|
9590
|
+
}
|
|
9591
|
+
if (input === "a") return answer();
|
|
9592
|
+
if (pm.phase === "answering") {
|
|
9593
|
+
if (input === "S") return s().showPremortemAnswers(id);
|
|
9594
|
+
if (input === "t" && row?.mine) return go.confirm(`Take back “${cut(row.answer.body.replace(/\s+/g, " "), 40)}”? u undoes it.`, () => s().deleteReflection(row.answer.id), "Take it back");
|
|
9595
|
+
return;
|
|
9596
|
+
}
|
|
9597
|
+
if (!row) return;
|
|
9598
|
+
const pending = !row.answer.raidId && !row.answer.skipped;
|
|
9599
|
+
if (input === "r" && pending) {
|
|
9600
|
+
if (s().raisePremortemAnswer(row.answer.id)) go.notify("Added to Risks. Enter opens it.");
|
|
9601
|
+
} else if (input === "m" && pending && row.same) {
|
|
9602
|
+
s().foldPremortemAnswer(row.answer.id, row.same.id);
|
|
9603
|
+
go.notify(`Folded into “${cut(row.same.title, 40)}”.`);
|
|
9604
|
+
} else if (input === "l" && !row.answer.raidId) s().skipPremortemAnswer(row.answer.id, !row.answer.skipped);
|
|
9605
|
+
}, { isActive: active !== false });
|
|
9606
|
+
if (!project || !pm) return null;
|
|
9607
|
+
const frame = (children, hints) => /* @__PURE__ */ jsxs(Box, {
|
|
9608
|
+
flexDirection: "column",
|
|
9609
|
+
width,
|
|
9610
|
+
height,
|
|
9611
|
+
borderStyle: "round",
|
|
9612
|
+
borderColor: theme.border,
|
|
9613
|
+
paddingX: 1,
|
|
9614
|
+
children: [
|
|
9615
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
9616
|
+
wrap: "truncate-end",
|
|
9617
|
+
children: [
|
|
9618
|
+
/* @__PURE__ */ jsx(Text, {
|
|
9619
|
+
color: theme.coral,
|
|
9620
|
+
children: "Pre-mortem"
|
|
9621
|
+
}),
|
|
9622
|
+
" ",
|
|
9623
|
+
/* @__PURE__ */ jsx(Text, {
|
|
9624
|
+
bold: true,
|
|
9625
|
+
children: project.name
|
|
9626
|
+
})
|
|
9627
|
+
]
|
|
9628
|
+
}),
|
|
9629
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
9630
|
+
children,
|
|
9631
|
+
/* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
|
|
9632
|
+
/* @__PURE__ */ jsx(Text, {
|
|
9633
|
+
color: theme.muted,
|
|
9634
|
+
wrap: "truncate-end",
|
|
9635
|
+
children: hints
|
|
9636
|
+
})
|
|
9637
|
+
]
|
|
9638
|
+
});
|
|
9639
|
+
if (pm.phase === "none") {
|
|
9640
|
+
const ends = project.endDate ? `after ${shortDay(project.endDate, today())}` : "three months from now";
|
|
9641
|
+
return frame(/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
9642
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
9643
|
+
wrap: "wrap",
|
|
9644
|
+
children: [
|
|
9645
|
+
"Imagine it's ",
|
|
9646
|
+
ends,
|
|
9647
|
+
" and this project failed. Everyone writes what went wrong, alone, for five minutes. Then the answers show together, and each one can go straight into the register."
|
|
9648
|
+
]
|
|
9649
|
+
}),
|
|
9650
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
9651
|
+
/* @__PURE__ */ jsx(Text, {
|
|
9652
|
+
color: theme.muted,
|
|
9653
|
+
children: viewOnly ? "You can view this harbor, so you can't start one." : "s starts it now."
|
|
9654
|
+
})
|
|
9655
|
+
] }), viewOnly ? "Esc close" : "s start · Esc close");
|
|
9656
|
+
}
|
|
9657
|
+
const selected = row?.answer.body ?? "";
|
|
9658
|
+
const fullRows = Math.min(4, Math.max(1, Math.ceil(selected.length / Math.max(20, width - 4))));
|
|
9659
|
+
const listRows = Math.max(3, height - 2 - 2 - 2 - 2 - fullRows - 2);
|
|
9660
|
+
const view = windowOf(rows, Math.min(at, rows.length - 1), listRows);
|
|
9661
|
+
const hints = pm.phase === "answering" ? [
|
|
9662
|
+
"↑↓ answer",
|
|
9663
|
+
viewOnly ? null : "a answer",
|
|
9664
|
+
viewOnly || !row?.mine ? null : "t take back",
|
|
9665
|
+
viewOnly ? null : "S show them now",
|
|
9666
|
+
"Esc close"
|
|
9667
|
+
] : [
|
|
9668
|
+
"↑↓ answer",
|
|
9669
|
+
row?.risk ? "Enter open the risk" : null,
|
|
9670
|
+
viewOnly || !row || row.answer.raidId || row.answer.skipped ? null : "r add as risk",
|
|
9671
|
+
viewOnly || !row?.same || row.answer.raidId || row.answer.skipped ? null : `m same as “${cut(row.same.title, 24)}”`,
|
|
9672
|
+
viewOnly || !row || row.answer.raidId ? null : row.answer.skipped ? "l put back" : "l leave out",
|
|
9673
|
+
viewOnly ? null : "a another answer",
|
|
9674
|
+
"Esc close"
|
|
9675
|
+
];
|
|
9676
|
+
return frame(/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
9677
|
+
/* @__PURE__ */ jsx(Text, {
|
|
9678
|
+
bold: true,
|
|
9679
|
+
wrap: "truncate-end",
|
|
9680
|
+
children: pm.question
|
|
9681
|
+
}),
|
|
9682
|
+
/* @__PURE__ */ jsx(Text, {
|
|
9683
|
+
color: theme.muted,
|
|
9684
|
+
wrap: "truncate-end",
|
|
9685
|
+
children: pm.phase === "answering" ? `Answers show together in ${clockText(pm.left)}. Until then you see only yours.` : `${pm.total} answer${pm.total === 1 ? "" : "s"}${pm.outcome ? ` · ${pm.outcome}` : ""}`
|
|
9686
|
+
}),
|
|
9687
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
9688
|
+
rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
9689
|
+
color: theme.muted,
|
|
9690
|
+
children: pm.phase === "answering" ? viewOnly ? "No answers of yours. The crew's show when the time is up." : "No answers of yours yet. a writes one: a sentence or two for each thing that could sink it." : "Nobody answered."
|
|
9691
|
+
}) : null,
|
|
9692
|
+
view.items.map((r, n) => {
|
|
9693
|
+
const on = view.start + n === Math.min(at, rows.length - 1);
|
|
9694
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
9695
|
+
wrap: "truncate-end",
|
|
9696
|
+
color: on ? theme.selected : r.answer.skipped && !r.answer.raidId ? theme.muted : void 0,
|
|
9697
|
+
bold: on,
|
|
9698
|
+
children: [
|
|
9699
|
+
on ? "› " : " ",
|
|
9700
|
+
r.tag ? /* @__PURE__ */ jsxs(Text, {
|
|
9701
|
+
color: r.answer.raidId ? theme.coral : theme.muted,
|
|
9702
|
+
children: [r.tag, " "]
|
|
9703
|
+
}) : null,
|
|
9704
|
+
r.answer.body.replace(/\s+/g, " "),
|
|
9705
|
+
r.mine ? /* @__PURE__ */ jsx(Text, {
|
|
9706
|
+
color: theme.muted,
|
|
9707
|
+
children: " · yours"
|
|
9708
|
+
}) : null,
|
|
9709
|
+
r.same ? /* @__PURE__ */ jsxs(Text, {
|
|
9710
|
+
color: theme.warn,
|
|
9711
|
+
children: [
|
|
9712
|
+
" · like “",
|
|
9713
|
+
cut(r.same.title, 30),
|
|
9714
|
+
"”"
|
|
9715
|
+
]
|
|
9716
|
+
}) : null
|
|
9717
|
+
]
|
|
9718
|
+
}, r.answer.id);
|
|
9719
|
+
}),
|
|
9720
|
+
row ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: " " }), /* @__PURE__ */ jsx(Box, {
|
|
9721
|
+
height: fullRows,
|
|
9722
|
+
overflow: "hidden",
|
|
9723
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
9724
|
+
wrap: "wrap",
|
|
9725
|
+
children: selected
|
|
9726
|
+
})
|
|
9727
|
+
})] }) : null
|
|
9728
|
+
] }), hints.filter(Boolean).join(" · "));
|
|
9729
|
+
}
|
|
9730
|
+
|
|
8909
9731
|
//#endregion
|
|
8910
9732
|
//#region src/tui/views/bands.tsx
|
|
8911
9733
|
/**
|
|
@@ -9650,6 +10472,7 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
9650
10472
|
init_swatches();
|
|
9651
10473
|
init_format();
|
|
9652
10474
|
init_hill();
|
|
10475
|
+
init_clock();
|
|
9653
10476
|
/** One project: its board (columns side by side) or its list, and the keys to work it. */
|
|
9654
10477
|
function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
9655
10478
|
const { store, today } = useDeps();
|
|
@@ -9659,6 +10482,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
9659
10482
|
const boardConfigs = useHarbor((s) => s.boardConfigs);
|
|
9660
10483
|
const reflections = useHarbor((s) => s.reflections);
|
|
9661
10484
|
const milestones = useHarbor((s) => s.milestones);
|
|
10485
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
9662
10486
|
const project = projects.find((p) => p.id === projectId);
|
|
9663
10487
|
const data = useMemo(() => ({
|
|
9664
10488
|
tasks,
|
|
@@ -9718,6 +10542,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
9718
10542
|
if (input === "e") return go.openPanel("project", projectId);
|
|
9719
10543
|
if (input === "b") return go.openPanel("board", projectId);
|
|
9720
10544
|
if (input === "R") return go.create("raid", { projectId });
|
|
10545
|
+
if (input === "P") return go.openPanel("premortem", projectId);
|
|
9721
10546
|
if (input === "f" && mode !== "budget") return go.pick("Show cards assigned to", [
|
|
9722
10547
|
{
|
|
9723
10548
|
label: "Anyone",
|
|
@@ -9796,6 +10621,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
9796
10621
|
const tide = tideLine(data, project, day);
|
|
9797
10622
|
const filtered = [filter.who === "none" ? "nobody's" : filter.who !== "any" ? `${people.find((p) => p.id === filter.who)?.name ?? "someone"}'s` : null, filter.priority !== "any" ? PRIORITY_LABEL[filter.priority].toLowerCase() : null].filter(Boolean);
|
|
9798
10623
|
const lead = people.find((p) => p.id === project.ownerId);
|
|
10624
|
+
const pmNote = premortemNote({
|
|
10625
|
+
reflections,
|
|
10626
|
+
raidItems
|
|
10627
|
+
}, project, clockNow(), day);
|
|
9799
10628
|
const opened = freshlyOpened({
|
|
9800
10629
|
reflections,
|
|
9801
10630
|
milestones
|
|
@@ -9864,17 +10693,24 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
9864
10693
|
}),
|
|
9865
10694
|
/* @__PURE__ */ jsxs(Text, {
|
|
9866
10695
|
wrap: "truncate-end",
|
|
9867
|
-
children: [
|
|
9868
|
-
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
project
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
10696
|
+
children: [
|
|
10697
|
+
/* @__PURE__ */ jsx(Text, {
|
|
10698
|
+
color: tide?.late ? theme.warn : theme.muted,
|
|
10699
|
+
children: tide ? `${tide.text} · ` : ""
|
|
10700
|
+
}),
|
|
10701
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
10702
|
+
color: isStill(project, day) ? theme.warn : theme.muted,
|
|
10703
|
+
children: [
|
|
10704
|
+
project.hillAt == null ? "Not on the hill · } puts it on" : `Hill: ${hillText(project).toLowerCase()}`,
|
|
10705
|
+
isStill(project, day) ? `, still ${stillFor(project, day)} days` : "",
|
|
10706
|
+
project.hillAt == null ? "" : " · { } move it"
|
|
10707
|
+
]
|
|
10708
|
+
}),
|
|
10709
|
+
pmNote ? /* @__PURE__ */ jsxs(Text, {
|
|
10710
|
+
color: theme.muted,
|
|
10711
|
+
children: [" · ", pmNote]
|
|
10712
|
+
}) : null
|
|
10713
|
+
]
|
|
9878
10714
|
}),
|
|
9879
10715
|
opened && /* @__PURE__ */ jsxs(Text, {
|
|
9880
10716
|
wrap: "truncate-end",
|
|
@@ -11499,8 +12335,14 @@ const NAV = [
|
|
|
11499
12335
|
{
|
|
11500
12336
|
screen: "brief",
|
|
11501
12337
|
label: "Brief"
|
|
12338
|
+
},
|
|
12339
|
+
{
|
|
12340
|
+
screen: "crew",
|
|
12341
|
+
label: "Crew"
|
|
11502
12342
|
}
|
|
11503
12343
|
];
|
|
12344
|
+
/** The key that opens a sidebar screen: 1 to 9, 0 for the tenth, C for the crew. */
|
|
12345
|
+
const navKey = (i) => i < 10 ? String((i + 1) % 10) : "C";
|
|
11504
12346
|
/** The sidebar's width: its longest line ("9 Calendar", ": Anything") with room either side. */
|
|
11505
12347
|
const NAV_WIDTH = 15;
|
|
11506
12348
|
/** What the corner says about saving, and in which of the theme's colours. */
|
|
@@ -11688,6 +12530,7 @@ function App({ ui }) {
|
|
|
11688
12530
|
if (input === ":") return setOverlay({ kind: "palette" });
|
|
11689
12531
|
if (input === "?") return setOverlay({ kind: "help" });
|
|
11690
12532
|
if (input === "H") return setOverlay({ kind: "harbors" });
|
|
12533
|
+
if (input === "C") return goScreen("crew");
|
|
11691
12534
|
if (input === "r") {
|
|
11692
12535
|
sync.retryNow();
|
|
11693
12536
|
return notify("Saving now.");
|
|
@@ -11887,6 +12730,10 @@ function App({ ui }) {
|
|
|
11887
12730
|
id: panel.id,
|
|
11888
12731
|
...panelProps
|
|
11889
12732
|
});
|
|
12733
|
+
else if (panel?.kind === "premortem") panelNode = /* @__PURE__ */ jsx(PremortemPanel, {
|
|
12734
|
+
id: panel.id,
|
|
12735
|
+
...panelProps
|
|
12736
|
+
});
|
|
11890
12737
|
const hints = panel ? [["Esc", "back"], ["u", "undo"]] : page?.kind === "project" ? [
|
|
11891
12738
|
["←→↑↓", "move"],
|
|
11892
12739
|
["Enter", "open"],
|
|
@@ -11897,6 +12744,7 @@ function App({ ui }) {
|
|
|
11897
12744
|
["v", "board/list/budget"],
|
|
11898
12745
|
["f p", "filter"],
|
|
11899
12746
|
["e", "details"],
|
|
12747
|
+
["P", "pre-mortem"],
|
|
11900
12748
|
["Esc", "back"]
|
|
11901
12749
|
] : page ? [
|
|
11902
12750
|
["↑↓", "move"],
|
|
@@ -11941,6 +12789,7 @@ function App({ ui }) {
|
|
|
11941
12789
|
else if (screen === "roadmap") screenNode = /* @__PURE__ */ jsx(RoadmapView, { ...viewProps });
|
|
11942
12790
|
else if (screen === "calendar") screenNode = /* @__PURE__ */ jsx(CalendarView, { ...viewProps });
|
|
11943
12791
|
else if (screen === "brief") screenNode = /* @__PURE__ */ jsx(BriefView, { ...viewProps });
|
|
12792
|
+
else if (screen === "crew") screenNode = /* @__PURE__ */ jsx(CrewView, { ...viewProps });
|
|
11944
12793
|
else screenNode = /* @__PURE__ */ jsx(HistoryView, { ...viewProps });
|
|
11945
12794
|
return /* @__PURE__ */ jsxs(Box, {
|
|
11946
12795
|
flexDirection: "column",
|
|
@@ -12002,7 +12851,7 @@ function App({ ui }) {
|
|
|
12002
12851
|
wrap: "truncate-end",
|
|
12003
12852
|
children: [/* @__PURE__ */ jsxs(Text, {
|
|
12004
12853
|
color: theme.muted,
|
|
12005
|
-
children: [(i
|
|
12854
|
+
children: [navKey(i), " "]
|
|
12006
12855
|
}), x.label]
|
|
12007
12856
|
}, x.screen);
|
|
12008
12857
|
}),
|
|
@@ -13659,54 +14508,6 @@ var init_tasks = __esmMin((() => {
|
|
|
13659
14508
|
init_assignment();
|
|
13660
14509
|
}));
|
|
13661
14510
|
|
|
13662
|
-
//#endregion
|
|
13663
|
-
//#region src/lib/premortem.ts
|
|
13664
|
-
/** A new pre-mortem started now: answers show together five minutes on. */
|
|
13665
|
-
function newPremortem(startedById, now) {
|
|
13666
|
-
return {
|
|
13667
|
-
startedAt: now.toISOString(),
|
|
13668
|
-
startedById,
|
|
13669
|
-
revealAt: new Date(now.getTime() + 5 * 6e4).toISOString()
|
|
13670
|
-
};
|
|
13671
|
-
}
|
|
13672
|
-
/**
|
|
13673
|
-
* A risk's title from an answer: its first sentence, kept under 120
|
|
13674
|
-
* characters at a word boundary, without a closing full stop.
|
|
13675
|
-
*/
|
|
13676
|
-
function riskTitle(answer) {
|
|
13677
|
-
const flat = answer.replace(/\s+/g, " ").trim();
|
|
13678
|
-
const first = /^(.+?[.!?])(\s|$)/.exec(flat)?.[1] ?? flat;
|
|
13679
|
-
let title = first.length <= 120 ? first : `${first.slice(0, 117).replace(/\s+\S*$/, "")}…`;
|
|
13680
|
-
title = title.replace(/\.$/, "");
|
|
13681
|
-
return title;
|
|
13682
|
-
}
|
|
13683
|
-
/**
|
|
13684
|
-
* The risk an answer becomes: on its project, open, unrated, with the whole
|
|
13685
|
-
* answer as what could happen. Scoped to the project alone, like a risk
|
|
13686
|
-
* logged on the project page; the program and portfolio roll it up from there.
|
|
13687
|
-
*/
|
|
13688
|
-
function riskFromAnswer(answer, project) {
|
|
13689
|
-
return {
|
|
13690
|
-
kind: "risk",
|
|
13691
|
-
title: riskTitle(answer.body),
|
|
13692
|
-
description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
|
|
13693
|
-
projectId: project.id,
|
|
13694
|
-
status: "open",
|
|
13695
|
-
ownerId: null,
|
|
13696
|
-
dueDate: null
|
|
13697
|
-
};
|
|
13698
|
-
}
|
|
13699
|
-
/** The risk's description with another answer folded in as the same worry. */
|
|
13700
|
-
function withFoldedAnswer(description, answer) {
|
|
13701
|
-
const base = description.trim();
|
|
13702
|
-
return `${base}${base ? "\n\n" : ""}Also raised: ${answer.trim()}`;
|
|
13703
|
-
}
|
|
13704
|
-
var PREMORTEM_MINUTES, STOP;
|
|
13705
|
-
var init_premortem = __esmMin((() => {
|
|
13706
|
-
PREMORTEM_MINUTES = 5;
|
|
13707
|
-
STOP = new Set("the a an and or but of to in on for with at by from into onto was were be been being is are it its this that these those we our us they them their he she his her i my me you your had has have did do does not no so too very just than then there here what which who because about after before over under again more most some any all each other only own same can could would should will got get project failed happened went".split(" "));
|
|
13708
|
-
}));
|
|
13709
|
-
|
|
13710
14511
|
//#endregion
|
|
13711
14512
|
//#region src/lib/raid-history.ts
|
|
13712
14513
|
/**
|
|
@@ -14830,7 +15631,9 @@ function plainTransport(t) {
|
|
|
14830
15631
|
save: async (body) => plainDeep(await t.save(body)),
|
|
14831
15632
|
wait: async (workspaceId, since) => plainDeep(await t.wait(workspaceId, since)),
|
|
14832
15633
|
history: async (query) => plainDeep(await t.history(query)),
|
|
14833
|
-
create: async (name) => plainDeep(await t.create(name))
|
|
15634
|
+
create: async (name) => plainDeep(await t.create(name)),
|
|
15635
|
+
crew: async (workspaceId, act) => plainDeep(await t.crew(workspaceId, act)),
|
|
15636
|
+
invite: async (workspaceId, email, role) => plainDeep(await t.invite(workspaceId, email, role))
|
|
14834
15637
|
};
|
|
14835
15638
|
}
|
|
14836
15639
|
function httpTransport(source, fetcher = fetch) {
|
|
@@ -14883,6 +15686,17 @@ function httpTransport(source, fetcher = fetch) {
|
|
|
14883
15686
|
create: (name) => post({
|
|
14884
15687
|
op: "create",
|
|
14885
15688
|
name
|
|
15689
|
+
}),
|
|
15690
|
+
crew: (workspaceId, act) => post({
|
|
15691
|
+
op: "crew",
|
|
15692
|
+
workspaceId,
|
|
15693
|
+
do: act
|
|
15694
|
+
}),
|
|
15695
|
+
invite: (workspaceId, email, role) => post({
|
|
15696
|
+
op: "invite",
|
|
15697
|
+
harbor: workspaceId,
|
|
15698
|
+
email,
|
|
15699
|
+
role
|
|
14886
15700
|
})
|
|
14887
15701
|
};
|
|
14888
15702
|
}
|
|
@@ -15587,6 +16401,184 @@ var init_folder = __esmMin((() => {
|
|
|
15587
16401
|
PLAN_DIR = ".harbor";
|
|
15588
16402
|
}));
|
|
15589
16403
|
|
|
16404
|
+
//#endregion
|
|
16405
|
+
//#region src/tui/engine/memory-crew.ts
|
|
16406
|
+
function memoryCrew(account, harbor) {
|
|
16407
|
+
let n = 0;
|
|
16408
|
+
const token = () => `t${++n}`;
|
|
16409
|
+
const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
16410
|
+
const members = [{
|
|
16411
|
+
userId: account.id,
|
|
16412
|
+
name: account.name,
|
|
16413
|
+
email: account.email,
|
|
16414
|
+
role: "owner"
|
|
16415
|
+
}, {
|
|
16416
|
+
userId: "acct-2",
|
|
16417
|
+
name: "Mara Quinn",
|
|
16418
|
+
email: "mara@example.com",
|
|
16419
|
+
role: "member"
|
|
16420
|
+
}];
|
|
16421
|
+
const invites = [];
|
|
16422
|
+
const shares = [];
|
|
16423
|
+
const forms = [];
|
|
16424
|
+
const apps = [{
|
|
16425
|
+
clientId: "harbor-cli",
|
|
16426
|
+
name: "Harbor CLI",
|
|
16427
|
+
returnsTo: "127.0.0.1",
|
|
16428
|
+
connectedAt: stamp(),
|
|
16429
|
+
active: true,
|
|
16430
|
+
canWrite: true,
|
|
16431
|
+
fullAccess: true
|
|
16432
|
+
}, {
|
|
16433
|
+
clientId: "claude",
|
|
16434
|
+
name: "Claude",
|
|
16435
|
+
returnsTo: "claude.ai",
|
|
16436
|
+
connectedAt: stamp(),
|
|
16437
|
+
active: true,
|
|
16438
|
+
canWrite: false,
|
|
16439
|
+
fullAccess: false
|
|
16440
|
+
}];
|
|
16441
|
+
const email = {
|
|
16442
|
+
weeklyDigest: true,
|
|
16443
|
+
assignments: true
|
|
16444
|
+
};
|
|
16445
|
+
const keep = {
|
|
16446
|
+
kept: false,
|
|
16447
|
+
interval: null,
|
|
16448
|
+
stripe: true,
|
|
16449
|
+
keptAt: null,
|
|
16450
|
+
trialEndsAt: new Date(Date.now() + 1728e6).toISOString(),
|
|
16451
|
+
paused: false
|
|
16452
|
+
};
|
|
16453
|
+
const refuse = (message, status = 404) => {
|
|
16454
|
+
throw new TransportError(message, status);
|
|
16455
|
+
};
|
|
16456
|
+
const view = (said = {}) => structuredClone({
|
|
16457
|
+
...said,
|
|
16458
|
+
pretend: true,
|
|
16459
|
+
harbor: {
|
|
16460
|
+
...harbor,
|
|
16461
|
+
role: "owner"
|
|
16462
|
+
},
|
|
16463
|
+
you: account.id,
|
|
16464
|
+
verified: true,
|
|
16465
|
+
members,
|
|
16466
|
+
invites: invites.map((i) => ({
|
|
16467
|
+
...i,
|
|
16468
|
+
url: `${ORIGIN}/join/${i.token}`
|
|
16469
|
+
})),
|
|
16470
|
+
shares: shares.map((s) => ({
|
|
16471
|
+
...s,
|
|
16472
|
+
url: `${ORIGIN}/share/${s.token}`
|
|
16473
|
+
})),
|
|
16474
|
+
forms: forms.map((f) => ({
|
|
16475
|
+
...f,
|
|
16476
|
+
url: `${ORIGIN}/intake/${f.token}`
|
|
16477
|
+
})),
|
|
16478
|
+
apps: apps.map((a) => ({
|
|
16479
|
+
...a,
|
|
16480
|
+
thisApp: a.clientId === "harbor-cli"
|
|
16481
|
+
})),
|
|
16482
|
+
email,
|
|
16483
|
+
keep,
|
|
16484
|
+
keepUrl: `${ORIGIN}/keep`
|
|
16485
|
+
});
|
|
16486
|
+
return {
|
|
16487
|
+
async crew(_id, act) {
|
|
16488
|
+
if (!act) return view();
|
|
16489
|
+
switch (act.act) {
|
|
16490
|
+
case "role": {
|
|
16491
|
+
const m = members.find((x) => x.userId === act.userId && x.role !== "owner");
|
|
16492
|
+
if (!m) return refuse("That isn't someone whose role can change.");
|
|
16493
|
+
m.role = act.role;
|
|
16494
|
+
return view();
|
|
16495
|
+
}
|
|
16496
|
+
case "withdraw-invite":
|
|
16497
|
+
invites.splice(0, invites.length, ...invites.filter((i) => i.token !== act.token));
|
|
16498
|
+
return view();
|
|
16499
|
+
case "share": {
|
|
16500
|
+
const made = {
|
|
16501
|
+
token: token(),
|
|
16502
|
+
portfolioId: act.portfolioId,
|
|
16503
|
+
createdAt: stamp(),
|
|
16504
|
+
subscribers: []
|
|
16505
|
+
};
|
|
16506
|
+
shares.unshift(made);
|
|
16507
|
+
return view({ url: `${ORIGIN}/share/${made.token}` });
|
|
16508
|
+
}
|
|
16509
|
+
case "withdraw-share":
|
|
16510
|
+
shares.splice(0, shares.length, ...shares.filter((s) => s.token !== act.token));
|
|
16511
|
+
return view();
|
|
16512
|
+
case "subscribe": {
|
|
16513
|
+
const share = shares.find((s) => s.token === act.token) ?? refuse("That share link isn't live any more.");
|
|
16514
|
+
const asked = act.emails.split(/[\s,;]+/).filter(Boolean);
|
|
16515
|
+
const ok = asked.filter((e) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)).map((e) => e.toLowerCase());
|
|
16516
|
+
const fresh = ok.filter((e) => !share.subscribers.some((s) => s.email === e));
|
|
16517
|
+
share.subscribers.push(...fresh.map((e) => ({
|
|
16518
|
+
email: e,
|
|
16519
|
+
stopped: false
|
|
16520
|
+
})));
|
|
16521
|
+
return view({
|
|
16522
|
+
added: fresh,
|
|
16523
|
+
invalid: asked.filter((e) => !ok.includes(e.toLowerCase())),
|
|
16524
|
+
alreadyThere: ok.length - fresh.length,
|
|
16525
|
+
overLimit: 0
|
|
16526
|
+
});
|
|
16527
|
+
}
|
|
16528
|
+
case "unsubscribe": {
|
|
16529
|
+
const share = shares.find((s) => s.token === act.token) ?? refuse("That share link isn't live any more.");
|
|
16530
|
+
share.subscribers = share.subscribers.filter((s) => s.stopped || s.email !== act.email);
|
|
16531
|
+
return view();
|
|
16532
|
+
}
|
|
16533
|
+
case "intake": {
|
|
16534
|
+
const made = {
|
|
16535
|
+
token: token(),
|
|
16536
|
+
projectId: act.projectId,
|
|
16537
|
+
createdAt: stamp()
|
|
16538
|
+
};
|
|
16539
|
+
forms.unshift(made);
|
|
16540
|
+
return view({ url: `${ORIGIN}/intake/${made.token}` });
|
|
16541
|
+
}
|
|
16542
|
+
case "withdraw-intake":
|
|
16543
|
+
forms.splice(0, forms.length, ...forms.filter((f) => f.token !== act.token));
|
|
16544
|
+
return view();
|
|
16545
|
+
case "disconnect":
|
|
16546
|
+
apps.splice(0, apps.length, ...apps.filter((a) => a.clientId !== act.clientId));
|
|
16547
|
+
return view();
|
|
16548
|
+
case "email":
|
|
16549
|
+
if (act.weeklyDigest !== void 0) email.weeklyDigest = act.weeklyDigest;
|
|
16550
|
+
if (act.assignments !== void 0) email.assignments = act.assignments;
|
|
16551
|
+
return view();
|
|
16552
|
+
}
|
|
16553
|
+
},
|
|
16554
|
+
async invite(_id, address, role) {
|
|
16555
|
+
const lower = address.trim().toLowerCase();
|
|
16556
|
+
const again = invites.some((i) => i.email === lower);
|
|
16557
|
+
invites.splice(0, invites.length, ...invites.filter((i) => i.email !== lower));
|
|
16558
|
+
invites.unshift({
|
|
16559
|
+
token: token(),
|
|
16560
|
+
email: lower,
|
|
16561
|
+
name: null,
|
|
16562
|
+
role,
|
|
16563
|
+
createdAt: stamp(),
|
|
16564
|
+
url: ""
|
|
16565
|
+
});
|
|
16566
|
+
return {
|
|
16567
|
+
harbor: harbor.name,
|
|
16568
|
+
email: lower,
|
|
16569
|
+
role,
|
|
16570
|
+
sent: true,
|
|
16571
|
+
again
|
|
16572
|
+
};
|
|
16573
|
+
}
|
|
16574
|
+
};
|
|
16575
|
+
}
|
|
16576
|
+
var ORIGIN;
|
|
16577
|
+
var init_memory_crew = __esmMin((() => {
|
|
16578
|
+
init_transport();
|
|
16579
|
+
ORIGIN = "https://harborppm.com";
|
|
16580
|
+
}));
|
|
16581
|
+
|
|
15590
16582
|
//#endregion
|
|
15591
16583
|
//#region src/tui/engine/memory.ts
|
|
15592
16584
|
function memoryHarbor(start, opts) {
|
|
@@ -15632,8 +16624,14 @@ function memoryHarbor(start, opts) {
|
|
|
15632
16624
|
const unreachable = () => {
|
|
15633
16625
|
if (offline || closed) throw new TransportError("Can't reach Harbor.", 0, true);
|
|
15634
16626
|
};
|
|
16627
|
+
const crew = memoryCrew(opts.account, {
|
|
16628
|
+
id,
|
|
16629
|
+
name
|
|
16630
|
+
});
|
|
15635
16631
|
return {
|
|
15636
16632
|
transport: {
|
|
16633
|
+
crew: opts.noCrew ? async () => Promise.reject(new TransportError(opts.noCrew, 404)) : crew.crew,
|
|
16634
|
+
invite: opts.noCrew ? async () => Promise.reject(new TransportError(opts.noCrew, 404)) : crew.invite,
|
|
15637
16635
|
async harbors() {
|
|
15638
16636
|
return {
|
|
15639
16637
|
account: opts.account,
|
|
@@ -15745,6 +16743,7 @@ var init_memory = __esmMin((() => {
|
|
|
15745
16743
|
init_row_changes();
|
|
15746
16744
|
init_task_keys();
|
|
15747
16745
|
init_indexes();
|
|
16746
|
+
init_memory_crew();
|
|
15748
16747
|
init_transport();
|
|
15749
16748
|
}));
|
|
15750
16749
|
|
|
@@ -16218,7 +17217,8 @@ function localTransport(folder, data, config) {
|
|
|
16218
17217
|
id: "local",
|
|
16219
17218
|
name: config.project.name
|
|
16220
17219
|
},
|
|
16221
|
-
waitMs: 6e4
|
|
17220
|
+
waitMs: 6e4,
|
|
17221
|
+
noCrew: "A plan kept in this folder has no crew. harbor init PROJECT links the folder to a project in a harbor, where the crew lives."
|
|
16222
17222
|
});
|
|
16223
17223
|
let writing = Promise.resolve();
|
|
16224
17224
|
return {
|
|
@@ -16480,7 +17480,8 @@ async function startApp(deps, options = {}) {
|
|
|
16480
17480
|
account,
|
|
16481
17481
|
harbors,
|
|
16482
17482
|
today,
|
|
16483
|
-
remember: deps.remember
|
|
17483
|
+
remember: deps.remember,
|
|
17484
|
+
browse: deps.browse
|
|
16484
17485
|
},
|
|
16485
17486
|
children: /* @__PURE__ */ jsx(App, { ui })
|
|
16486
17487
|
}));
|
|
@@ -16559,7 +17560,8 @@ async function runApp(opts) {
|
|
|
16559
17560
|
splashMs: SPLASH_MS,
|
|
16560
17561
|
theme,
|
|
16561
17562
|
firstRun,
|
|
16562
|
-
remember: opts.remember
|
|
17563
|
+
remember: opts.remember,
|
|
17564
|
+
browse: opts.browse
|
|
16563
17565
|
}, { alternateScreen: true });
|
|
16564
17566
|
await instance.waitUntilExit();
|
|
16565
17567
|
}
|