@harborppm/cli 0.79.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.mjs CHANGED
@@ -5913,7 +5913,7 @@ const STATUS_ORDER = [
5913
5913
  "review",
5914
5914
  "done"
5915
5915
  ];
5916
- const PRIORITIES$1 = [
5916
+ const PRIORITIES$2 = [
5917
5917
  "low",
5918
5918
  "medium",
5919
5919
  "high",
@@ -5938,7 +5938,7 @@ function isLate(task, today) {
5938
5938
  function myOpenTasks(data, personId, today) {
5939
5939
  const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
5940
5940
  const rank = (t) => isLate(t, today) ? 0 : t.dueDate ? 1 : 2;
5941
- return mine.sort((a, b) => rank(a) - rank(b) || (a.dueDate ?? "").localeCompare(b.dueDate ?? "") || PRIORITIES$1.indexOf(b.priority) - PRIORITIES$1.indexOf(a.priority));
5941
+ return mine.sort((a, b) => rank(a) - rank(b) || (a.dueDate ?? "").localeCompare(b.dueDate ?? "") || PRIORITIES$2.indexOf(b.priority) - PRIORITIES$2.indexOf(a.priority));
5942
5942
  }
5943
5943
  /** Others' open tasks that wait on one of this person's. */
5944
5944
  function waitingOnMe(data, personId) {
@@ -9975,6 +9975,7 @@ function inboxOf(data, personId, now = clockNow()) {
9975
9975
  detail: `${r.kind === "issue" ? "Issue" : "Risk"}${where ? ` in ${where}` : ""} · its review date has passed`,
9976
9976
  at: r.dueDate,
9977
9977
  href: `/risks/${r.id}`,
9978
+ raidId: r.id,
9978
9979
  personId: null
9979
9980
  });
9980
9981
  }
@@ -10064,6 +10065,7 @@ function InboxView({ active, width, height, go }) {
10064
10065
  const open = (p) => {
10065
10066
  store.getState().readPing(p.id);
10066
10067
  if (p.taskId) go.openTask(p.taskId);
10068
+ else if (p.raidId) go.openPanel("raid", p.raidId);
10067
10069
  else if (p.programId) go.openProgram(p.programId);
10068
10070
  };
10069
10071
  useInput((input, key) => {
@@ -13235,6 +13237,7 @@ const PAGES = {
13235
13237
  projects: "/projects",
13236
13238
  risks: "/risks",
13237
13239
  goals: "/goals",
13240
+ reports: "/reports",
13238
13241
  calendar: "/calendar",
13239
13242
  people: "/people"
13240
13243
  };
@@ -13759,6 +13762,269 @@ function keyResultText(kr, work) {
13759
13762
  }
13760
13763
  const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
13761
13764
 
13765
+ // src/lib/reports.ts
13766
+ init_date_fns();
13767
+ init_date_text();
13768
+ init_indexes();
13769
+ init_swatches();
13770
+ /** Weeks a "by week" chart shows: back from this week, or ahead for due dates. */
13771
+ const WEEKS_BACK = 12;
13772
+ const WEEKS_AHEAD = 8;
13773
+ /** Bars past this many are summed into "Everything else". */
13774
+ const MAX_BARS = 12;
13775
+ const GROUPS = [
13776
+ "status",
13777
+ "owner",
13778
+ "priority",
13779
+ "project",
13780
+ "program",
13781
+ "week"
13782
+ ];
13783
+ const MEASURE_LABEL = {
13784
+ open: "Open tasks",
13785
+ done: "Finished tasks",
13786
+ all: "All tasks",
13787
+ overdue: "Overdue tasks",
13788
+ hours: "Hours logged"
13789
+ };
13790
+ const GROUP_LABEL = {
13791
+ status: "Status",
13792
+ owner: "Owner",
13793
+ priority: "Priority",
13794
+ project: "Project",
13795
+ program: "Program",
13796
+ week: "Week"
13797
+ };
13798
+ /** The splits a measure can take: hours have no status or priority of their own. */
13799
+ function groupsFor(measure) {
13800
+ return measure === "hours" ? [
13801
+ "owner",
13802
+ "project",
13803
+ "program",
13804
+ "week"
13805
+ ] : GROUPS;
13806
+ }
13807
+ const STATUS_FILL = {
13808
+ backlog: "var(--color-swatch-slate)",
13809
+ todo: "var(--color-swatch-sand)",
13810
+ doing: "var(--color-swatch-sea)",
13811
+ review: "var(--color-swatch-rust)",
13812
+ done: "var(--color-swatch-moss)"
13813
+ };
13814
+ const PRIORITY_FILL = {
13815
+ low: "var(--color-swatch-slate)",
13816
+ medium: "var(--color-swatch-sea)",
13817
+ high: "var(--color-swatch-sand)",
13818
+ urgent: "var(--color-swatch-coral)"
13819
+ };
13820
+ const PRIORITIES$1 = [
13821
+ "urgent",
13822
+ "high",
13823
+ "medium",
13824
+ "low"
13825
+ ];
13826
+ /** Colours handed out in turn where a slice has none of its own. */
13827
+ const CYCLE = [
13828
+ "sea",
13829
+ "sand",
13830
+ "moss",
13831
+ "rust",
13832
+ "slate",
13833
+ "pine",
13834
+ "clay",
13835
+ "sage"
13836
+ ];
13837
+ const swatchFill = (s) => `var(--color-swatch-${s})`;
13838
+ /** The Monday on or before a day or moment (ISO, read in local time), or "" when it isn't a date. */
13839
+ function mondayOf(day) {
13840
+ const d = parseIso(day.length > 10 ? day : `${day}T12:00:00`);
13841
+ if (Number.isNaN(d.getTime())) return "";
13842
+ const back = (d.getDay() + 6) % 7;
13843
+ return format(addDays(d, -back), "yyyy-MM-dd");
13844
+ }
13845
+ /** The projects a chart reads: everything, or what sits under its scope. */
13846
+ function scopeOf(data, scopeId) {
13847
+ if (!scopeId) return {
13848
+ projectIds: null,
13849
+ name: "Whole harbor",
13850
+ found: true
13851
+ };
13852
+ const project = byId(data.projects).get(scopeId);
13853
+ if (project) return {
13854
+ projectIds: /* @__PURE__ */ new Set([project.id]),
13855
+ name: project.name,
13856
+ found: true
13857
+ };
13858
+ const program = byId(data.programs).get(scopeId);
13859
+ if (program) {
13860
+ const ids = data.projects.filter((p) => p.programId === program.id).map((p) => p.id);
13861
+ return {
13862
+ projectIds: new Set(ids),
13863
+ name: program.name,
13864
+ found: true
13865
+ };
13866
+ }
13867
+ const portfolio = byId(data.portfolios).get(scopeId);
13868
+ if (portfolio) {
13869
+ const programIds = new Set(data.programs.filter((p) => p.portfolioId === portfolio.id).map((p) => p.id));
13870
+ const ids = data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id);
13871
+ return {
13872
+ projectIds: new Set(ids),
13873
+ name: portfolio.name,
13874
+ found: true
13875
+ };
13876
+ }
13877
+ return {
13878
+ projectIds: /* @__PURE__ */ new Set(),
13879
+ name: "Deleted",
13880
+ found: false
13881
+ };
13882
+ }
13883
+ /**
13884
+ * What a chart shows today: its slices in a fixed order (statuses and
13885
+ * priorities in board order, weeks in time order, the rest largest first,
13886
+ * past MAX_BARS summed into "Everything else"), with their total.
13887
+ */
13888
+ function chartData(chart, data, today) {
13889
+ const scope = scopeOf(data, chart.scopeId);
13890
+ const inScope = (t) => !scope.projectIds || scope.projectIds.has(t.projectId);
13891
+ const unit = chart.measure === "hours" ? "hours" : "tasks";
13892
+ const groupBy = groupsFor(chart.measure).includes(chart.groupBy) ? chart.groupBy : "owner";
13893
+ const items = [];
13894
+ for (const task of data.tasks) {
13895
+ if (!inScope(task)) continue;
13896
+ const done = task.status === "done";
13897
+ switch (chart.measure) {
13898
+ case "open":
13899
+ if (!done) items.push({
13900
+ task,
13901
+ amount: 1,
13902
+ day: task.dueDate
13903
+ });
13904
+ break;
13905
+ case "done":
13906
+ if (done) items.push({
13907
+ task,
13908
+ amount: 1,
13909
+ day: task.completedAt
13910
+ });
13911
+ break;
13912
+ case "all":
13913
+ items.push({
13914
+ task,
13915
+ amount: 1,
13916
+ day: task.createdAt
13917
+ });
13918
+ break;
13919
+ case "overdue":
13920
+ if (!done && task.dueDate && task.dueDate.slice(0, 10) < today) items.push({
13921
+ task,
13922
+ amount: 1,
13923
+ day: task.dueDate
13924
+ });
13925
+ break;
13926
+ case "hours": for (const entry of task.time ?? []) items.push({
13927
+ task,
13928
+ amount: entry.minutes / 60,
13929
+ day: entry.on,
13930
+ personId: entry.personId
13931
+ });
13932
+ }
13933
+ }
13934
+ const projects = byId(data.projects);
13935
+ const programs = byId(data.programs);
13936
+ const people = byId(data.people);
13937
+ const sums = /* @__PURE__ */ new Map();
13938
+ const add = (key, amount) => sums.set(key, (sums.get(key) ?? 0) + amount);
13939
+ let weeks = [];
13940
+ if (groupBy === "week") {
13941
+ const thisWeek = mondayOf(today);
13942
+ const ahead = chart.measure === "open";
13943
+ const first = ahead ? thisWeek : format(addDays(parseIso(`${thisWeek}T12:00:00`), -77), "yyyy-MM-dd");
13944
+ const count = ahead ? 8 : 12;
13945
+ const start = parseIso(`${first}T12:00:00`);
13946
+ weeks = Array.from({ length: count }, (_, i) => format(addDays(start, 7 * i), "yyyy-MM-dd"));
13947
+ const known = new Set(weeks);
13948
+ for (const item of items) {
13949
+ if (!item.day) continue;
13950
+ const week = mondayOf(item.day);
13951
+ if (known.has(week)) add(week, item.amount);
13952
+ else if (ahead && week < first) add(first, item.amount);
13953
+ }
13954
+ } else for (const item of items) {
13955
+ const t = item.task;
13956
+ add(groupBy === "status" ? t.status : groupBy === "priority" ? t.priority : groupBy === "project" ? t.projectId : groupBy === "program" ? projects.get(t.projectId)?.programId ?? "" : (chart.measure === "hours" ? item.personId : t.assigneeId) ?? "", item.amount);
13957
+ }
13958
+ const round = (n) => unit === "hours" ? Math.round(n * 10) / 10 : n;
13959
+ let slices;
13960
+ if (groupBy === "week") slices = weeks.map((w) => ({
13961
+ key: w,
13962
+ label: format(parseIso(`${w}T12:00:00`), "MMM d"),
13963
+ value: round(sums.get(w) ?? 0),
13964
+ fill: "var(--color-primary)"
13965
+ }));
13966
+ else if (groupBy === "status") slices = TASK_COLUMNS.filter((s) => sums.has(s)).map((s) => ({
13967
+ key: s,
13968
+ label: TASK_STATUS_LABEL[s],
13969
+ value: round(sums.get(s)),
13970
+ fill: STATUS_FILL[s]
13971
+ }));
13972
+ else if (groupBy === "priority") slices = PRIORITIES$1.filter((p) => sums.has(p)).map((p) => ({
13973
+ key: p,
13974
+ label: PRIORITY_LABEL[p],
13975
+ value: round(sums.get(p)),
13976
+ fill: PRIORITY_FILL[p]
13977
+ }));
13978
+ else {
13979
+ const named = [...sums.entries()].map(([key, value], i) => {
13980
+ if (groupBy === "owner") {
13981
+ const person = key ? people.get(key) : void 0;
13982
+ return {
13983
+ key: key || "nobody",
13984
+ label: key ? person?.name ?? "Former member" : "Nobody",
13985
+ value: round(value),
13986
+ fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
13987
+ };
13988
+ }
13989
+ const thing = groupBy === "project" ? projects.get(key) : programs.get(key);
13990
+ return {
13991
+ key: key || "none",
13992
+ label: thing?.name ?? "Deleted",
13993
+ value: round(value),
13994
+ fill: thing?.swatch ? swatchFill(thing.swatch) : swatchFill(CYCLE[i % CYCLE.length])
13995
+ };
13996
+ });
13997
+ named.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
13998
+ if (named.length > 12) {
13999
+ const rest = named.slice(11);
14000
+ slices = [...named.slice(0, 11), {
14001
+ key: "rest",
14002
+ label: `Everything else (${rest.length})`,
14003
+ value: round(rest.reduce((n, s) => n + s.value, 0)),
14004
+ fill: "var(--color-swatch-slate)"
14005
+ }];
14006
+ } else slices = named;
14007
+ }
14008
+ const total = round(slices.reduce((n, s) => n + s.value, 0));
14009
+ return {
14010
+ slices,
14011
+ total,
14012
+ unit,
14013
+ scopeName: scope.name,
14014
+ scopeFound: scope.found
14015
+ };
14016
+ }
14017
+ /** "12 tasks", "1 task", "7.5 hours". */
14018
+ function amountText(value, unit) {
14019
+ if (unit === "hours") return `${value} ${value === 1 ? "hour" : "hours"}`;
14020
+ return `${value} ${value === 1 ? "task" : "tasks"}`;
14021
+ }
14022
+ /** A chart's text for screen readers, agents and the terminal: "Open tasks by owner: Sam 4, Ana 2 (6 tasks)". */
14023
+ function chartText(title, result) {
14024
+ if (result.slices.length === 0) return `${title}: nothing yet`;
14025
+ return `${title}: ${result.slices.map((s) => `${s.label} ${s.value}`).join(", ")} (${amountText(result.total, result.unit)})`;
14026
+ }
14027
+
13762
14028
  // node_modules/zod/v4/core/util.js
13763
14029
  function getEnumValues(entries) {
13764
14030
  const numericValues = Object.values(entries).filter((v) => typeof v === "number");
@@ -18956,7 +19222,7 @@ init_blockers();
18956
19222
  init_format();
18957
19223
  init_indexes();
18958
19224
  const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
18959
- const days$1 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
19225
+ const days$2 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
18960
19226
  const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
18961
19227
  const open = (t) => t.status !== "done";
18962
19228
  /** True when `a` finishes later than `b`, or as late behind a longer line. */
@@ -19023,7 +19289,7 @@ function linksOf(line, walk, target, today) {
19023
19289
  task,
19024
19290
  finish,
19025
19291
  late: Boolean(task.dueDate && task.dueDate.slice(0, 10) < today),
19026
- slack: against ? days$1(finish, against) : null
19292
+ slack: against ? days$2(finish, against) : null
19027
19293
  };
19028
19294
  });
19029
19295
  }
@@ -19035,7 +19301,7 @@ function chainOf(line, walk, target, today) {
19035
19301
  links,
19036
19302
  finish,
19037
19303
  target,
19038
- over: target ? days$1(target, finish) : null,
19304
+ over: target ? days$2(target, finish) : null,
19039
19305
  undated: 0
19040
19306
  };
19041
19307
  }
@@ -19116,17 +19382,17 @@ function milestonePole(milestone, data, now) {
19116
19382
  const others = runs.slice(1).map((r) => ({
19117
19383
  project: r.project,
19118
19384
  finish: r.finish,
19119
- slack: days$1(r.finish, due)
19385
+ slack: days$2(r.finish, due)
19120
19386
  }));
19121
19387
  return {
19122
19388
  kind: "chain",
19123
19389
  links: first.pole.kind === "chain" ? first.pole.links.map((l, i, all) => i === all.length - 1 ? {
19124
19390
  ...l,
19125
- slack: days$1(l.finish, due)
19391
+ slack: days$2(l.finish, due)
19126
19392
  } : l) : [],
19127
19393
  finish: first.finish,
19128
19394
  target: due,
19129
- over: days$1(due, first.finish),
19395
+ over: days$2(due, first.finish),
19130
19396
  via: {
19131
19397
  project: first.project,
19132
19398
  finish: first.finish
@@ -19209,7 +19475,8 @@ function pickData(s) {
19209
19475
  spendEntries: s.spendEntries ?? NONE,
19210
19476
  reflections: s.reflections ?? NONE,
19211
19477
  priorityRules: s.priorityRules ?? null,
19212
- workCalendar: s.workCalendar ?? null
19478
+ workCalendar: s.workCalendar ?? null,
19479
+ dashboards: s.dashboards ?? NONE
19213
19480
  };
19214
19481
  }
19215
19482
  /** The document as sent to the server: normalized and without inline file data. */
@@ -21435,6 +21702,67 @@ const HARBOR_TOOLS = [
21435
21702
  })) };
21436
21703
  }
21437
21704
  }),
21705
+ tool({
21706
+ name: "list_dashboards",
21707
+ title: "Dashboards",
21708
+ description: "The harbor's dashboards from the Reports page, each chart with what it counts (open, finished, overdue or all tasks, or hours logged), how it's split (status, owner, priority, project, program or week), the part of the harbor it reads, and its numbers as of today, with a one-line text of each." + DATA_NOTE,
21709
+ input: object({
21710
+ harbor: harborArg,
21711
+ dashboard: string().max(200).optional().describe("Dashboard id or name. Default: all.")
21712
+ }),
21713
+ run: (ctx, input) => {
21714
+ const { data } = ctx;
21715
+ const today = todayISO(ctx.now);
21716
+ const all = data.dashboards ?? [];
21717
+ return { dashboards: (input.dashboard ? [findOne(all, input.dashboard, "dashboard")] : all).map((d) => ({
21718
+ id: d.id,
21719
+ name: d.name,
21720
+ charts: d.charts.map((c) => {
21721
+ const result = chartData(c, data, today);
21722
+ return {
21723
+ id: c.id,
21724
+ title: c.title,
21725
+ counts: MEASURE_LABEL[c.measure],
21726
+ splitBy: GROUP_LABEL[c.groupBy],
21727
+ view: c.view,
21728
+ from: result.scopeName,
21729
+ total: amountText(result.total, result.unit),
21730
+ values: result.slices.map((s) => ({
21731
+ label: s.label,
21732
+ value: s.value
21733
+ })),
21734
+ text: chartText(c.title, result)
21735
+ };
21736
+ })
21737
+ })) };
21738
+ }
21739
+ }),
21740
+ tool({
21741
+ name: "list_task_templates",
21742
+ title: "Task templates",
21743
+ description: "A project's task templates (or every project's), to start new tasks from with create_task's template: each one's name, the title and notes it gives, its priority, owner, checklist and how many days after it's made the task is due." + DATA_NOTE,
21744
+ input: object({
21745
+ harbor: harborArg,
21746
+ project: string().max(200).optional().describe("Project id or name. Default: all.")
21747
+ }),
21748
+ run: (ctx, input) => {
21749
+ const { data } = ctx;
21750
+ const projects = input.project ? [findOne(data.projects, input.project, "project")] : data.projects.filter((p) => p.taskTemplates?.length);
21751
+ const name = (id) => id ? data.people.find((p) => p.id === id)?.name ?? null : null;
21752
+ return { templates: projects.flatMap((p) => (p.taskTemplates ?? []).map((t) => ({
21753
+ id: t.id,
21754
+ name: t.name,
21755
+ project: p.name,
21756
+ title: t.title,
21757
+ notes: t.description || void 0,
21758
+ priority: t.priority,
21759
+ owner: name(t.assigneeId),
21760
+ alsoOn: (t.followerIds ?? []).map(name).filter(Boolean),
21761
+ checklist: t.checklist,
21762
+ dueInDays: t.dueInDays
21763
+ }))) };
21764
+ }
21765
+ }),
21438
21766
  tool({
21439
21767
  name: "long_pole",
21440
21768
  title: "What's holding this up",
@@ -22246,7 +22574,8 @@ var init_undo = __esmMin((() => {
22246
22574
  "attachments",
22247
22575
  "checkIns",
22248
22576
  "spendEntries",
22249
- "reflections"
22577
+ "reflections",
22578
+ "dashboards"
22250
22579
  ];
22251
22580
  FOLD_MS = 5e3;
22252
22581
  LEAD = [
@@ -22613,6 +22942,9 @@ function hierarchySlice({ get, write }) {
22613
22942
  ...patch
22614
22943
  } : p) });
22615
22944
  },
22945
+ setDashboards: (dashboards) => {
22946
+ write({ dashboards });
22947
+ },
22616
22948
  updateProgram: (id, patch) => {
22617
22949
  write({ programs: get().programs.map((p) => p.id === id ? {
22618
22950
  ...p,
@@ -22942,6 +23274,16 @@ var init_github_refs = __esmMin((() => {
22942
23274
  }));
22943
23275
 
22944
23276
  // src/lib/automations.ts
23277
+ /** Whether a task meets the rule's "only if" line. */
23278
+ function meetsCondition(rule, task) {
23279
+ const c = rule.onlyIf;
23280
+ if (!c) return true;
23281
+ if (c.priority && task.priority !== c.priority) return false;
23282
+ if (c.assignee) {
23283
+ if (c.assignee === "nobody" ? task.assigneeId != null : task.assigneeId !== c.assignee) return false;
23284
+ }
23285
+ return true;
23286
+ }
22945
23287
  /**
22946
23288
  * Checked once, against the (before, after) pair of a single user-driven
22947
23289
  * mutation — never re-checked against an automation's own effect. That's the
@@ -22959,6 +23301,64 @@ function matches(rule, prev, next, created) {
22959
23301
  default: return false;
22960
23302
  }
22961
23303
  }
23304
+ /** One rule's actions on one task, in order. */
23305
+ function runActions(actions, start, ctx) {
23306
+ let task = start;
23307
+ const comments = [];
23308
+ for (const action of actions) switch (action.type) {
23309
+ case "set_status": {
23310
+ if (!action.status) break;
23311
+ const wasDone = task.status === "done";
23312
+ const completedAt = action.status === "done" ? task.completedAt ?? ctx.now : wasDone ? null : task.completedAt;
23313
+ task = {
23314
+ ...task,
23315
+ status: action.status,
23316
+ completedAt
23317
+ };
23318
+ break;
23319
+ }
23320
+ case "set_priority":
23321
+ if (action.priority) task = {
23322
+ ...task,
23323
+ priority: action.priority
23324
+ };
23325
+ break;
23326
+ case "set_assignee":
23327
+ task = {
23328
+ ...task,
23329
+ assigneeId: action.assigneeId ?? null
23330
+ };
23331
+ break;
23332
+ case "set_due": {
23333
+ const n = Math.min(365, Math.max(0, Math.round(action.inDays ?? 0)));
23334
+ task = {
23335
+ ...task,
23336
+ dueDate: dayAfter(ctx.today, n)
23337
+ };
23338
+ break;
23339
+ }
23340
+ case "add_follower": {
23341
+ const id = action.personId;
23342
+ if (!id || id === task.assigneeId || task.followerIds?.includes(id)) break;
23343
+ task = {
23344
+ ...task,
23345
+ followerIds: [...task.followerIds ?? [], id]
23346
+ };
23347
+ break;
23348
+ }
23349
+ case "add_comment": if (action.commentBody?.trim()) comments.push({
23350
+ taskId: task.id,
23351
+ authorId: ctx.authorId,
23352
+ body: action.commentBody.trim(),
23353
+ createdAt: ctx.now,
23354
+ mentionedIds: []
23355
+ });
23356
+ }
23357
+ return {
23358
+ task,
23359
+ comments
23360
+ };
23361
+ }
22962
23362
  /**
22963
23363
  * Applies every enabled rule on `project` whose trigger matches this one
22964
23364
  * mutation (`prev` -> `next`, or `prev` undefined for a brand-new task) and
@@ -22969,49 +23369,32 @@ function matches(rule, prev, next, created) {
22969
23369
  function applyAutomations(project, prev, next, opts) {
22970
23370
  const rules = project?.automations ?? [];
22971
23371
  const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
23372
+ const today = opts.now ? opts.now.slice(0, 10) : todayISO();
23373
+ const ctx = {
23374
+ authorId: opts.currentUserId,
23375
+ now,
23376
+ today
23377
+ };
22972
23378
  let task = next;
22973
23379
  const comments = [];
22974
23380
  for (const rule of rules) {
22975
- if (!matches(rule, prev, next, opts.created)) continue;
22976
- for (const action of rule.actions) switch (action.type) {
22977
- case "set_status": {
22978
- if (!action.status) break;
22979
- const wasDone = task.status === "done";
22980
- const completedAt = action.status === "done" ? task.completedAt ?? now : wasDone ? null : task.completedAt;
22981
- task = {
22982
- ...task,
22983
- status: action.status,
22984
- completedAt
22985
- };
22986
- break;
22987
- }
22988
- case "set_priority":
22989
- if (action.priority) task = {
22990
- ...task,
22991
- priority: action.priority
22992
- };
22993
- break;
22994
- case "set_assignee":
22995
- task = {
22996
- ...task,
22997
- assigneeId: action.assigneeId ?? null
22998
- };
22999
- break;
23000
- case "add_comment": if (action.commentBody?.trim()) comments.push({
23001
- taskId: task.id,
23002
- authorId: opts.currentUserId,
23003
- body: action.commentBody.trim(),
23004
- createdAt: now,
23005
- mentionedIds: []
23006
- });
23007
- }
23381
+ if (!matches(rule, prev, next, opts.created) || !meetsCondition(rule, next)) continue;
23382
+ const ran = runActions(rule.actions, task, ctx);
23383
+ task = ran.task;
23384
+ comments.push(...ran.comments);
23008
23385
  }
23009
23386
  return {
23010
23387
  task,
23011
23388
  comments
23012
23389
  };
23013
23390
  }
23014
- var init_automations = __esmMin((() => {}));
23391
+ var dayAfter;
23392
+ var init_automations = __esmMin((() => {
23393
+ init_date_fns();
23394
+ init_date_text();
23395
+ init_format();
23396
+ dayAfter = (day, n) => format(addDays(parseIso(day), n), "yyyy-MM-dd");
23397
+ }));
23015
23398
 
23016
23399
  // src/lib/focus.ts
23017
23400
  function focusIdsOf(data, personId, date = todayISO()) {
@@ -24499,7 +24882,7 @@ function pickCollections(doc, keys) {
24499
24882
  function emptyRows() {
24500
24883
  return Object.fromEntries(ROW_COLLECTIONS.map((k) => [k, []]));
24501
24884
  }
24502
- /** A collection as rows; `spendEntries`, `reflections`, `priorityRules` and `workCalendar` may be missing from older documents. */
24885
+ /** A collection as rows; `spendEntries`, `reflections`, `priorityRules`, `workCalendar` and `dashboards` may be missing from older documents. */
24503
24886
  function rowsOf(doc, key) {
24504
24887
  if (isKeyed(key)) return KEYED[key].rows(doc[key]);
24505
24888
  return doc[key] ?? [];
@@ -24584,7 +24967,8 @@ var init_changes = __esmMin((() => {
24584
24967
  "spendEntries",
24585
24968
  "reflections",
24586
24969
  "priorityRules",
24587
- "workCalendar"
24970
+ "workCalendar",
24971
+ "dashboards"
24588
24972
  ];
24589
24973
  ROW_COLLECTIONS = [
24590
24974
  "tasks",
@@ -24605,7 +24989,8 @@ var init_changes = __esmMin((() => {
24605
24989
  "ropes",
24606
24990
  "readPingIds",
24607
24991
  "priorityRules",
24608
- "workCalendar"
24992
+ "workCalendar",
24993
+ "dashboards"
24609
24994
  ];
24610
24995
  KEYED = {
24611
24996
  boardConfigs: {
@@ -28404,7 +28789,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
28404
28789
  if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
28405
28790
  label: "Any priority",
28406
28791
  value: "any"
28407
- }, ...PRIORITIES$1.map((p) => ({
28792
+ }, ...PRIORITIES$2.map((p) => ({
28408
28793
  label: PRIORITY_LABEL[p],
28409
28794
  value: p
28410
28795
  }))], (priority) => setFilter((f) => ({
@@ -29024,13 +29409,18 @@ const TRIGGER_LABEL = {
29024
29409
  task_created: "A task is created",
29025
29410
  status_changed_to: "Status is set to…",
29026
29411
  priority_changed_to: "Priority is set to…",
29027
- assigned: "A task is assigned to anyone"
29412
+ assigned: "A task is assigned to anyone",
29413
+ due_soon: "A task is due soon",
29414
+ overdue: "A task becomes overdue",
29415
+ untouched: "A task sits untouched"
29028
29416
  };
29029
29417
  const ACTION_LABEL = {
29030
29418
  set_status: "Set status to…",
29031
29419
  set_priority: "Set priority to…",
29032
29420
  set_assignee: "Assign to…",
29033
- add_comment: "Post a comment"
29421
+ add_comment: "Post a comment",
29422
+ set_due: "Move the due date",
29423
+ add_follower: "Add a follower"
29034
29424
  };
29035
29425
  function describeAction(a, peopleName) {
29036
29426
  switch (a.type) {
@@ -29038,18 +29428,36 @@ function describeAction(a, peopleName) {
29038
29428
  case "set_priority": return `set priority to ${a.priority ? PRIORITY_LABEL[a.priority] : "…"}`;
29039
29429
  case "set_assignee": return `assign to ${peopleName(a.assigneeId)}`;
29040
29430
  case "add_comment": return `post “${a.commentBody ?? ""}”`;
29431
+ case "set_due": {
29432
+ const n = a.inDays ?? 0;
29433
+ return n === 0 ? "set the due date to that day" : `set the due date ${n} ${n === 1 ? "day" : "days"} out`;
29434
+ }
29435
+ case "add_follower": return `add ${peopleName(a.personId)} as a follower`;
29041
29436
  }
29042
29437
  }
29438
+ const days$1 = (n) => `${n} ${n === 1 ? "day" : "days"}`;
29043
29439
  function describeTrigger(rule) {
29044
29440
  const t = rule.trigger;
29045
29441
  if (t.type === "status_changed_to") return `When status is set to ${t.status ? TASK_STATUS_LABEL[t.status] : "…"}`;
29046
29442
  if (t.type === "priority_changed_to") return `When priority is set to ${t.priority ? PRIORITY_LABEL[t.priority] : "…"}`;
29443
+ if (t.type === "due_soon") return `When a task is due within ${days$1(t.days ?? 2)}`;
29444
+ if (t.type === "untouched") return `When nobody touches a task for ${days$1(t.days ?? 7)}`;
29047
29445
  return `When ${TRIGGER_LABEL[t.type].toLowerCase()}`;
29048
29446
  }
29447
+ /** The "only if" line: "only High priority, owned by Ana", or "" when there's none. */
29448
+ function describeCondition(rule, peopleName) {
29449
+ const c = rule.onlyIf;
29450
+ if (!c) return "";
29451
+ const parts = [];
29452
+ if (c.priority) parts.push(`${PRIORITY_LABEL[c.priority]} priority`);
29453
+ if (c.assignee) parts.push(c.assignee === "nobody" ? "with no owner" : `owned by ${peopleName(c.assignee)}`);
29454
+ return parts.length ? `only ${parts.join(", ")}` : "";
29455
+ }
29049
29456
  /** "When status is set to Done: set priority to Low, post “Shipped”". */
29050
29457
  function describeRule(rule, peopleName) {
29051
29458
  const did = rule.actions.map((a) => describeAction(a, peopleName)).join(", ");
29052
- return `${describeTrigger(rule)}: ${did || "no actions"}`;
29459
+ const only = describeCondition(rule, peopleName);
29460
+ return `${describeTrigger(rule)}${only ? ` (${only})` : ""}: ${did || "no actions"}`;
29053
29461
  }
29054
29462
  function defaultAction(type) {
29055
29463
  if (type === "set_status") return {
@@ -29064,6 +29472,14 @@ function defaultAction(type) {
29064
29472
  type,
29065
29473
  assigneeId: null
29066
29474
  };
29475
+ if (type === "set_due") return {
29476
+ type,
29477
+ inDays: 3
29478
+ };
29479
+ if (type === "add_follower") return {
29480
+ type,
29481
+ personId: null
29482
+ };
29067
29483
  return {
29068
29484
  type,
29069
29485
  commentBody: ""
@@ -29103,6 +29519,7 @@ function readFieldValue(def, text) {
29103
29519
  * starts from, each column (name, shown, cap, place), lanes and what cards
29104
29520
  * show, the project's own fields and its automations. b on a project opens it.
29105
29521
  */
29522
+ init_automations();
29106
29523
  init_board();
29107
29524
  init_swatches();
29108
29525
  const LANES = [
@@ -29137,7 +29554,7 @@ const statusChoices = () => TASK_COLUMNS.map((s) => ({
29137
29554
  label: TASK_STATUS_LABEL[s],
29138
29555
  value: s
29139
29556
  }));
29140
- const priorityChoices = () => PRIORITIES$1.map((p) => ({
29557
+ const priorityChoices = () => PRIORITIES$2.map((p) => ({
29141
29558
  label: PRIORITY_LABEL[p],
29142
29559
  value: p
29143
29560
  }));
@@ -29391,6 +29808,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29391
29808
  type,
29392
29809
  priority: p
29393
29810
  }));
29811
+ if (type === "due_soon" || type === "untouched") return go.ask(type === "due_soon" ? "Due within how many days?" : "Untouched for how many days?", (text) => {
29812
+ const n = Math.round(Number(text.trim()));
29813
+ if (!Number.isFinite(n) || n < 1) return;
29814
+ askAction({
29815
+ type,
29816
+ days: Math.min(60, n)
29817
+ });
29818
+ });
29394
29819
  askAction({ type });
29395
29820
  }
29396
29821
  function askAction(trigger) {
@@ -29424,6 +29849,21 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29424
29849
  ...defaultAction(type),
29425
29850
  assigneeId: pid
29426
29851
  }));
29852
+ if (type === "set_due") return go.ask("Set the due date how many days out?", (text) => {
29853
+ const n = Math.round(Number(text.trim()));
29854
+ if (Number.isFinite(n) && n >= 0) add({
29855
+ type,
29856
+ inDays: Math.min(365, n)
29857
+ });
29858
+ });
29859
+ if (type === "add_follower") return go.pick("Add as a follower", people.map((p) => ({
29860
+ label: p.name,
29861
+ value: p.id,
29862
+ hint: p.role
29863
+ })), (pid) => add({
29864
+ type,
29865
+ personId: pid
29866
+ }));
29427
29867
  go.ask("Comment to post", (text) => text.trim() && add({
29428
29868
  type,
29429
29869
  commentBody: text.trim()
@@ -29822,11 +30262,11 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
29822
30262
  });
29823
30263
  } else if (f === "priority") editor = /* @__PURE__ */ jsx(Picker, {
29824
30264
  title: "Priority",
29825
- choices: PRIORITIES$1.map((p) => ({
30265
+ choices: PRIORITIES$2.map((p) => ({
29826
30266
  label: PRIORITY_LABEL[p],
29827
30267
  value: p
29828
30268
  })),
29829
- initial: PRIORITIES$1.indexOf(task.priority),
30269
+ initial: PRIORITIES$2.indexOf(task.priority),
29830
30270
  onCancel: done,
29831
30271
  onPick: (p) => {
29832
30272
  store.getState().updateTask(task.id, { priority: p });
package/lib/cli.mjs CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  followedText,
41
41
  soundingsText,
42
42
  goalsText,
43
+ templatesText,
43
44
  thanksText,
44
45
  ashoreText,
45
46
  voyageText,
@@ -166,7 +167,7 @@ export const HELP = `harbor: Harbor from the terminal
166
167
  [--also "NAME, NAME"] [--due DATE|pilot]
167
168
  [--priority low|medium|high|urgent]
168
169
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
169
- [--literal]
170
+ [--literal] [--template NAME]
170
171
  reads plain words in TITLE as the
171
172
  website does: @name, by friday,
172
173
  !high, #project (--literal: keep
@@ -263,6 +264,10 @@ export const HELP = `harbor: Harbor from the terminal
263
264
  the ones waiting on your answer
264
265
  harbor goals [PORTFOLIO] each portfolio's goals, how far
265
266
  along and whether on track
267
+ harbor templates [PROJECT] the task templates new tasks
268
+ start from (harbor add --template)
269
+ harbor template TASK [--name NAME] save a task as a template in its
270
+ project
266
271
  harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
267
272
  public holidays and time away
268
273
  coming up, and whose holidays
@@ -768,6 +773,11 @@ const DETAIL = {
768
773
  " ten lines with --comment add the same comment to all ten. More than 100",
769
774
  " lines is harbor import's job.",
770
775
  "",
776
+ " --template NAME starts from one of the project's task templates (save one",
777
+ " with Save as template on a task, or harbor template TASK): its title,",
778
+ " notes, priority, owner, others on it, checklist and due offset fill what",
779
+ " the flags don't. Leave out TITLE to take the template's.",
780
+ "",
771
781
  " --due pilot takes the earliest day the owner's queue suggests: their open",
772
782
  " work due first, their weekly pace, time away, what it waits on and how",
773
783
  " long their similar tasks took. It needs an owner (--assignee none is",
@@ -1026,6 +1036,16 @@ const DETAIL = {
1026
1036
  " these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
1027
1037
  " NZ US (national holidays only).",
1028
1038
  ],
1039
+ templates: [
1040
+ " harbor templates [PROJECT] reads: each project's task templates",
1041
+ " (all projects when left out)",
1042
+ " harbor template TASK [--name NAME] writes: saves the task as a template",
1043
+ " in its project, named after it unless",
1044
+ " --name says otherwise",
1045
+ "",
1046
+ " harbor add --project P --template NAME starts a task from one: its title,",
1047
+ " notes, priority, owner, others on it, checklist and due offset.",
1048
+ ],
1029
1049
  goals: [
1030
1050
  " harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
1031
1051
  " left out), off track first, with how far",
@@ -2478,12 +2498,15 @@ export async function run(argv, io = {}) {
2478
2498
  const { positionals, values, harbor } = parse(
2479
2499
  spec(
2480
2500
  ["over-limit", "dry-run", "literal"],
2481
- ["project", "assignee", "also", "due", "priority", "status", "comment"],
2501
+ ["project", "assignee", "also", "due", "priority", "status", "comment", "template"],
2482
2502
  ),
2483
2503
  );
2504
+ // --template with no title: the template's own title.
2505
+ const fromTemplate = values.template !== undefined && positionals.length === 0;
2484
2506
  // Several at once: one title a line on stdin, with no title (or -) after add.
2485
2507
  const piped =
2486
- positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
2508
+ !fromTemplate &&
2509
+ (positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-"));
2487
2510
  const stdin = io.stdin ?? process.stdin;
2488
2511
  const titles =
2489
2512
  piped && (io.stdin || !process.stdin.isTTY)
@@ -2491,7 +2514,9 @@ export async function run(argv, io = {}) {
2491
2514
  .split(/\r?\n/)
2492
2515
  .map((l) => l.trim())
2493
2516
  .filter(Boolean)
2494
- : [text(positionals, "the task")];
2517
+ : fromTemplate
2518
+ ? [""]
2519
+ : [text(positionals, "the task")];
2495
2520
  if (titles.length === 0)
2496
2521
  throw new HarborError("Say the task, or give one title a line on stdin.");
2497
2522
  if (titles.length > 100)
@@ -2500,7 +2525,7 @@ export async function run(argv, io = {}) {
2500
2525
  );
2501
2526
  // Plain words, as on the website: @person, by Friday, !high, #Project.
2502
2527
  // --literal keeps every title exactly as typed.
2503
- const reads = values.literal
2528
+ const reads = values.literal || fromTemplate
2504
2529
  ? titles.map((title) => ({ title, owner: null, due: null, priority: null, project: null }))
2505
2530
  : await readTitles(client, harbor, titles, values);
2506
2531
  // No --project and no #Project: the harbor's one open project, if that's all there is.
@@ -2569,9 +2594,10 @@ export async function run(argv, io = {}) {
2569
2594
  .call("create_task", {
2570
2595
  harbor,
2571
2596
  project: projectOf(words),
2572
- title: words.title,
2573
- // Yours unless you say otherwise, as with n in the app.
2574
- assignee: values.assignee ?? words.owner?.value ?? "me",
2597
+ title: words.title || undefined,
2598
+ template: values.template,
2599
+ // Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
2600
+ assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
2575
2601
  also: values.also === undefined ? undefined : alsoList(values.also),
2576
2602
  due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
2577
2603
  suggestedDue: duePilot(values.due),
@@ -3190,6 +3216,23 @@ export async function run(argv, io = {}) {
3190
3216
  soundingsText(r),
3191
3217
  );
3192
3218
  }
3219
+ case "templates": {
3220
+ const { positionals, harbor } = parse(spec());
3221
+ const project = positionals.join(" ").trim();
3222
+ return call("list_task_templates", { harbor, ...(project ? { project } : {}) }, (r) =>
3223
+ templatesText(r),
3224
+ );
3225
+ }
3226
+ case "template": {
3227
+ const { positionals, values, harbor } = parse(spec([], ["name"]));
3228
+ const task = text(positionals, "the task");
3229
+ return call(
3230
+ "save_task_template",
3231
+ { harbor, task, ...(values.name ? { name: values.name } : {}) },
3232
+ (r) =>
3233
+ `Saved “${r.template.name}” as a template in ${r.template.project}. harbor add --project "${r.template.project}" --template "${r.template.name}" starts a task from it.`,
3234
+ );
3235
+ }
3193
3236
  case "goals": {
3194
3237
  const { positionals, harbor } = parse(spec());
3195
3238
  const portfolio = positionals.join(" ").trim();
package/lib/format.mjs CHANGED
@@ -1231,6 +1231,30 @@ export function catchupText(r) {
1231
1231
  }
1232
1232
 
1233
1233
  /** list_goals as text: each portfolio's goals, what needs attention first, with their key results. */
1234
+ /** harbor templates: each project's task templates, with what a new task takes from them. */
1235
+ export function templatesText(r) {
1236
+ const list = r.templates ?? [];
1237
+ if (!list.length)
1238
+ return "No task templates yet. harbor template TASK saves one, or Save as template on a task on the website.";
1239
+ const lines = [];
1240
+ let project = null;
1241
+ for (const t of list) {
1242
+ if (t.project !== project) {
1243
+ if (project !== null) lines.push("");
1244
+ lines.push(t.project);
1245
+ project = t.project;
1246
+ }
1247
+ const bits = [];
1248
+ if (t.priority && t.priority !== "medium") bits.push(`${t.priority} priority`);
1249
+ if (t.owner) bits.push(t.owner);
1250
+ if (t.dueInDays != null)
1251
+ bits.push(t.dueInDays === 0 ? "due the day it's made" : `due in ${t.dueInDays} ${t.dueInDays === 1 ? "day" : "days"}`);
1252
+ if (t.checklist?.length) bits.push(`${t.checklist.length} checklist ${t.checklist.length === 1 ? "item" : "items"}`);
1253
+ lines.push(` ${t.name}${t.title !== t.name ? `: ${t.title}` : ""}${bits.length ? ` (${bits.join(", ")})` : ""}`);
1254
+ }
1255
+ return lines.join("\n");
1256
+ }
1257
+
1234
1258
  export function goalsText(r) {
1235
1259
  const order = ["off-track", "at-risk", "not-started", "on-track", "achieved"];
1236
1260
  const withGoals = (r.portfolios ?? []).filter((p) => p.goals?.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.79.0",
3
+ "version": "0.81.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",