@harborppm/cli 0.79.0 → 0.80.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) {
@@ -13235,6 +13235,7 @@ const PAGES = {
13235
13235
  projects: "/projects",
13236
13236
  risks: "/risks",
13237
13237
  goals: "/goals",
13238
+ reports: "/reports",
13238
13239
  calendar: "/calendar",
13239
13240
  people: "/people"
13240
13241
  };
@@ -13759,6 +13760,269 @@ function keyResultText(kr, work) {
13759
13760
  }
13760
13761
  const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
13761
13762
 
13763
+ // src/lib/reports.ts
13764
+ init_date_fns();
13765
+ init_date_text();
13766
+ init_indexes();
13767
+ init_swatches();
13768
+ /** Weeks a "by week" chart shows: back from this week, or ahead for due dates. */
13769
+ const WEEKS_BACK = 12;
13770
+ const WEEKS_AHEAD = 8;
13771
+ /** Bars past this many are summed into "Everything else". */
13772
+ const MAX_BARS = 12;
13773
+ const GROUPS = [
13774
+ "status",
13775
+ "owner",
13776
+ "priority",
13777
+ "project",
13778
+ "program",
13779
+ "week"
13780
+ ];
13781
+ const MEASURE_LABEL = {
13782
+ open: "Open tasks",
13783
+ done: "Finished tasks",
13784
+ all: "All tasks",
13785
+ overdue: "Overdue tasks",
13786
+ hours: "Hours logged"
13787
+ };
13788
+ const GROUP_LABEL = {
13789
+ status: "Status",
13790
+ owner: "Owner",
13791
+ priority: "Priority",
13792
+ project: "Project",
13793
+ program: "Program",
13794
+ week: "Week"
13795
+ };
13796
+ /** The splits a measure can take: hours have no status or priority of their own. */
13797
+ function groupsFor(measure) {
13798
+ return measure === "hours" ? [
13799
+ "owner",
13800
+ "project",
13801
+ "program",
13802
+ "week"
13803
+ ] : GROUPS;
13804
+ }
13805
+ const STATUS_FILL = {
13806
+ backlog: "var(--color-swatch-slate)",
13807
+ todo: "var(--color-swatch-sand)",
13808
+ doing: "var(--color-swatch-sea)",
13809
+ review: "var(--color-swatch-rust)",
13810
+ done: "var(--color-swatch-moss)"
13811
+ };
13812
+ const PRIORITY_FILL = {
13813
+ low: "var(--color-swatch-slate)",
13814
+ medium: "var(--color-swatch-sea)",
13815
+ high: "var(--color-swatch-sand)",
13816
+ urgent: "var(--color-swatch-coral)"
13817
+ };
13818
+ const PRIORITIES$1 = [
13819
+ "urgent",
13820
+ "high",
13821
+ "medium",
13822
+ "low"
13823
+ ];
13824
+ /** Colours handed out in turn where a slice has none of its own. */
13825
+ const CYCLE = [
13826
+ "sea",
13827
+ "sand",
13828
+ "moss",
13829
+ "rust",
13830
+ "slate",
13831
+ "pine",
13832
+ "clay",
13833
+ "sage"
13834
+ ];
13835
+ const swatchFill = (s) => `var(--color-swatch-${s})`;
13836
+ /** The Monday on or before a day or moment (ISO, read in local time), or "" when it isn't a date. */
13837
+ function mondayOf(day) {
13838
+ const d = parseIso(day.length > 10 ? day : `${day}T12:00:00`);
13839
+ if (Number.isNaN(d.getTime())) return "";
13840
+ const back = (d.getDay() + 6) % 7;
13841
+ return format(addDays(d, -back), "yyyy-MM-dd");
13842
+ }
13843
+ /** The projects a chart reads: everything, or what sits under its scope. */
13844
+ function scopeOf(data, scopeId) {
13845
+ if (!scopeId) return {
13846
+ projectIds: null,
13847
+ name: "Whole harbor",
13848
+ found: true
13849
+ };
13850
+ const project = byId(data.projects).get(scopeId);
13851
+ if (project) return {
13852
+ projectIds: /* @__PURE__ */ new Set([project.id]),
13853
+ name: project.name,
13854
+ found: true
13855
+ };
13856
+ const program = byId(data.programs).get(scopeId);
13857
+ if (program) {
13858
+ const ids = data.projects.filter((p) => p.programId === program.id).map((p) => p.id);
13859
+ return {
13860
+ projectIds: new Set(ids),
13861
+ name: program.name,
13862
+ found: true
13863
+ };
13864
+ }
13865
+ const portfolio = byId(data.portfolios).get(scopeId);
13866
+ if (portfolio) {
13867
+ const programIds = new Set(data.programs.filter((p) => p.portfolioId === portfolio.id).map((p) => p.id));
13868
+ const ids = data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id);
13869
+ return {
13870
+ projectIds: new Set(ids),
13871
+ name: portfolio.name,
13872
+ found: true
13873
+ };
13874
+ }
13875
+ return {
13876
+ projectIds: /* @__PURE__ */ new Set(),
13877
+ name: "Deleted",
13878
+ found: false
13879
+ };
13880
+ }
13881
+ /**
13882
+ * What a chart shows today: its slices in a fixed order (statuses and
13883
+ * priorities in board order, weeks in time order, the rest largest first,
13884
+ * past MAX_BARS summed into "Everything else"), with their total.
13885
+ */
13886
+ function chartData(chart, data, today) {
13887
+ const scope = scopeOf(data, chart.scopeId);
13888
+ const inScope = (t) => !scope.projectIds || scope.projectIds.has(t.projectId);
13889
+ const unit = chart.measure === "hours" ? "hours" : "tasks";
13890
+ const groupBy = groupsFor(chart.measure).includes(chart.groupBy) ? chart.groupBy : "owner";
13891
+ const items = [];
13892
+ for (const task of data.tasks) {
13893
+ if (!inScope(task)) continue;
13894
+ const done = task.status === "done";
13895
+ switch (chart.measure) {
13896
+ case "open":
13897
+ if (!done) items.push({
13898
+ task,
13899
+ amount: 1,
13900
+ day: task.dueDate
13901
+ });
13902
+ break;
13903
+ case "done":
13904
+ if (done) items.push({
13905
+ task,
13906
+ amount: 1,
13907
+ day: task.completedAt
13908
+ });
13909
+ break;
13910
+ case "all":
13911
+ items.push({
13912
+ task,
13913
+ amount: 1,
13914
+ day: task.createdAt
13915
+ });
13916
+ break;
13917
+ case "overdue":
13918
+ if (!done && task.dueDate && task.dueDate.slice(0, 10) < today) items.push({
13919
+ task,
13920
+ amount: 1,
13921
+ day: task.dueDate
13922
+ });
13923
+ break;
13924
+ case "hours": for (const entry of task.time ?? []) items.push({
13925
+ task,
13926
+ amount: entry.minutes / 60,
13927
+ day: entry.on,
13928
+ personId: entry.personId
13929
+ });
13930
+ }
13931
+ }
13932
+ const projects = byId(data.projects);
13933
+ const programs = byId(data.programs);
13934
+ const people = byId(data.people);
13935
+ const sums = /* @__PURE__ */ new Map();
13936
+ const add = (key, amount) => sums.set(key, (sums.get(key) ?? 0) + amount);
13937
+ let weeks = [];
13938
+ if (groupBy === "week") {
13939
+ const thisWeek = mondayOf(today);
13940
+ const ahead = chart.measure === "open";
13941
+ const first = ahead ? thisWeek : format(addDays(parseIso(`${thisWeek}T12:00:00`), -77), "yyyy-MM-dd");
13942
+ const count = ahead ? 8 : 12;
13943
+ const start = parseIso(`${first}T12:00:00`);
13944
+ weeks = Array.from({ length: count }, (_, i) => format(addDays(start, 7 * i), "yyyy-MM-dd"));
13945
+ const known = new Set(weeks);
13946
+ for (const item of items) {
13947
+ if (!item.day) continue;
13948
+ const week = mondayOf(item.day);
13949
+ if (known.has(week)) add(week, item.amount);
13950
+ else if (ahead && week < first) add(first, item.amount);
13951
+ }
13952
+ } else for (const item of items) {
13953
+ const t = item.task;
13954
+ 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);
13955
+ }
13956
+ const round = (n) => unit === "hours" ? Math.round(n * 10) / 10 : n;
13957
+ let slices;
13958
+ if (groupBy === "week") slices = weeks.map((w) => ({
13959
+ key: w,
13960
+ label: format(parseIso(`${w}T12:00:00`), "MMM d"),
13961
+ value: round(sums.get(w) ?? 0),
13962
+ fill: "var(--color-primary)"
13963
+ }));
13964
+ else if (groupBy === "status") slices = TASK_COLUMNS.filter((s) => sums.has(s)).map((s) => ({
13965
+ key: s,
13966
+ label: TASK_STATUS_LABEL[s],
13967
+ value: round(sums.get(s)),
13968
+ fill: STATUS_FILL[s]
13969
+ }));
13970
+ else if (groupBy === "priority") slices = PRIORITIES$1.filter((p) => sums.has(p)).map((p) => ({
13971
+ key: p,
13972
+ label: PRIORITY_LABEL[p],
13973
+ value: round(sums.get(p)),
13974
+ fill: PRIORITY_FILL[p]
13975
+ }));
13976
+ else {
13977
+ const named = [...sums.entries()].map(([key, value], i) => {
13978
+ if (groupBy === "owner") {
13979
+ const person = key ? people.get(key) : void 0;
13980
+ return {
13981
+ key: key || "nobody",
13982
+ label: key ? person?.name ?? "Former member" : "Nobody",
13983
+ value: round(value),
13984
+ fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
13985
+ };
13986
+ }
13987
+ const thing = groupBy === "project" ? projects.get(key) : programs.get(key);
13988
+ return {
13989
+ key: key || "none",
13990
+ label: thing?.name ?? "Deleted",
13991
+ value: round(value),
13992
+ fill: thing?.swatch ? swatchFill(thing.swatch) : swatchFill(CYCLE[i % CYCLE.length])
13993
+ };
13994
+ });
13995
+ named.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
13996
+ if (named.length > 12) {
13997
+ const rest = named.slice(11);
13998
+ slices = [...named.slice(0, 11), {
13999
+ key: "rest",
14000
+ label: `Everything else (${rest.length})`,
14001
+ value: round(rest.reduce((n, s) => n + s.value, 0)),
14002
+ fill: "var(--color-swatch-slate)"
14003
+ }];
14004
+ } else slices = named;
14005
+ }
14006
+ const total = round(slices.reduce((n, s) => n + s.value, 0));
14007
+ return {
14008
+ slices,
14009
+ total,
14010
+ unit,
14011
+ scopeName: scope.name,
14012
+ scopeFound: scope.found
14013
+ };
14014
+ }
14015
+ /** "12 tasks", "1 task", "7.5 hours". */
14016
+ function amountText(value, unit) {
14017
+ if (unit === "hours") return `${value} ${value === 1 ? "hour" : "hours"}`;
14018
+ return `${value} ${value === 1 ? "task" : "tasks"}`;
14019
+ }
14020
+ /** A chart's text for screen readers, agents and the terminal: "Open tasks by owner: Sam 4, Ana 2 (6 tasks)". */
14021
+ function chartText(title, result) {
14022
+ if (result.slices.length === 0) return `${title}: nothing yet`;
14023
+ return `${title}: ${result.slices.map((s) => `${s.label} ${s.value}`).join(", ")} (${amountText(result.total, result.unit)})`;
14024
+ }
14025
+
13762
14026
  // node_modules/zod/v4/core/util.js
13763
14027
  function getEnumValues(entries) {
13764
14028
  const numericValues = Object.values(entries).filter((v) => typeof v === "number");
@@ -18956,7 +19220,7 @@ init_blockers();
18956
19220
  init_format();
18957
19221
  init_indexes();
18958
19222
  const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
18959
- const days$1 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
19223
+ const days$2 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
18960
19224
  const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
18961
19225
  const open = (t) => t.status !== "done";
18962
19226
  /** True when `a` finishes later than `b`, or as late behind a longer line. */
@@ -19023,7 +19287,7 @@ function linksOf(line, walk, target, today) {
19023
19287
  task,
19024
19288
  finish,
19025
19289
  late: Boolean(task.dueDate && task.dueDate.slice(0, 10) < today),
19026
- slack: against ? days$1(finish, against) : null
19290
+ slack: against ? days$2(finish, against) : null
19027
19291
  };
19028
19292
  });
19029
19293
  }
@@ -19035,7 +19299,7 @@ function chainOf(line, walk, target, today) {
19035
19299
  links,
19036
19300
  finish,
19037
19301
  target,
19038
- over: target ? days$1(target, finish) : null,
19302
+ over: target ? days$2(target, finish) : null,
19039
19303
  undated: 0
19040
19304
  };
19041
19305
  }
@@ -19116,17 +19380,17 @@ function milestonePole(milestone, data, now) {
19116
19380
  const others = runs.slice(1).map((r) => ({
19117
19381
  project: r.project,
19118
19382
  finish: r.finish,
19119
- slack: days$1(r.finish, due)
19383
+ slack: days$2(r.finish, due)
19120
19384
  }));
19121
19385
  return {
19122
19386
  kind: "chain",
19123
19387
  links: first.pole.kind === "chain" ? first.pole.links.map((l, i, all) => i === all.length - 1 ? {
19124
19388
  ...l,
19125
- slack: days$1(l.finish, due)
19389
+ slack: days$2(l.finish, due)
19126
19390
  } : l) : [],
19127
19391
  finish: first.finish,
19128
19392
  target: due,
19129
- over: days$1(due, first.finish),
19393
+ over: days$2(due, first.finish),
19130
19394
  via: {
19131
19395
  project: first.project,
19132
19396
  finish: first.finish
@@ -19209,7 +19473,8 @@ function pickData(s) {
19209
19473
  spendEntries: s.spendEntries ?? NONE,
19210
19474
  reflections: s.reflections ?? NONE,
19211
19475
  priorityRules: s.priorityRules ?? null,
19212
- workCalendar: s.workCalendar ?? null
19476
+ workCalendar: s.workCalendar ?? null,
19477
+ dashboards: s.dashboards ?? NONE
19213
19478
  };
19214
19479
  }
19215
19480
  /** The document as sent to the server: normalized and without inline file data. */
@@ -21435,6 +21700,67 @@ const HARBOR_TOOLS = [
21435
21700
  })) };
21436
21701
  }
21437
21702
  }),
21703
+ tool({
21704
+ name: "list_dashboards",
21705
+ title: "Dashboards",
21706
+ 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,
21707
+ input: object({
21708
+ harbor: harborArg,
21709
+ dashboard: string().max(200).optional().describe("Dashboard id or name. Default: all.")
21710
+ }),
21711
+ run: (ctx, input) => {
21712
+ const { data } = ctx;
21713
+ const today = todayISO(ctx.now);
21714
+ const all = data.dashboards ?? [];
21715
+ return { dashboards: (input.dashboard ? [findOne(all, input.dashboard, "dashboard")] : all).map((d) => ({
21716
+ id: d.id,
21717
+ name: d.name,
21718
+ charts: d.charts.map((c) => {
21719
+ const result = chartData(c, data, today);
21720
+ return {
21721
+ id: c.id,
21722
+ title: c.title,
21723
+ counts: MEASURE_LABEL[c.measure],
21724
+ splitBy: GROUP_LABEL[c.groupBy],
21725
+ view: c.view,
21726
+ from: result.scopeName,
21727
+ total: amountText(result.total, result.unit),
21728
+ values: result.slices.map((s) => ({
21729
+ label: s.label,
21730
+ value: s.value
21731
+ })),
21732
+ text: chartText(c.title, result)
21733
+ };
21734
+ })
21735
+ })) };
21736
+ }
21737
+ }),
21738
+ tool({
21739
+ name: "list_task_templates",
21740
+ title: "Task templates",
21741
+ 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,
21742
+ input: object({
21743
+ harbor: harborArg,
21744
+ project: string().max(200).optional().describe("Project id or name. Default: all.")
21745
+ }),
21746
+ run: (ctx, input) => {
21747
+ const { data } = ctx;
21748
+ const projects = input.project ? [findOne(data.projects, input.project, "project")] : data.projects.filter((p) => p.taskTemplates?.length);
21749
+ const name = (id) => id ? data.people.find((p) => p.id === id)?.name ?? null : null;
21750
+ return { templates: projects.flatMap((p) => (p.taskTemplates ?? []).map((t) => ({
21751
+ id: t.id,
21752
+ name: t.name,
21753
+ project: p.name,
21754
+ title: t.title,
21755
+ notes: t.description || void 0,
21756
+ priority: t.priority,
21757
+ owner: name(t.assigneeId),
21758
+ alsoOn: (t.followerIds ?? []).map(name).filter(Boolean),
21759
+ checklist: t.checklist,
21760
+ dueInDays: t.dueInDays
21761
+ }))) };
21762
+ }
21763
+ }),
21438
21764
  tool({
21439
21765
  name: "long_pole",
21440
21766
  title: "What's holding this up",
@@ -22246,7 +22572,8 @@ var init_undo = __esmMin((() => {
22246
22572
  "attachments",
22247
22573
  "checkIns",
22248
22574
  "spendEntries",
22249
- "reflections"
22575
+ "reflections",
22576
+ "dashboards"
22250
22577
  ];
22251
22578
  FOLD_MS = 5e3;
22252
22579
  LEAD = [
@@ -22613,6 +22940,9 @@ function hierarchySlice({ get, write }) {
22613
22940
  ...patch
22614
22941
  } : p) });
22615
22942
  },
22943
+ setDashboards: (dashboards) => {
22944
+ write({ dashboards });
22945
+ },
22616
22946
  updateProgram: (id, patch) => {
22617
22947
  write({ programs: get().programs.map((p) => p.id === id ? {
22618
22948
  ...p,
@@ -22942,6 +23272,16 @@ var init_github_refs = __esmMin((() => {
22942
23272
  }));
22943
23273
 
22944
23274
  // src/lib/automations.ts
23275
+ /** Whether a task meets the rule's "only if" line. */
23276
+ function meetsCondition(rule, task) {
23277
+ const c = rule.onlyIf;
23278
+ if (!c) return true;
23279
+ if (c.priority && task.priority !== c.priority) return false;
23280
+ if (c.assignee) {
23281
+ if (c.assignee === "nobody" ? task.assigneeId != null : task.assigneeId !== c.assignee) return false;
23282
+ }
23283
+ return true;
23284
+ }
22945
23285
  /**
22946
23286
  * Checked once, against the (before, after) pair of a single user-driven
22947
23287
  * mutation — never re-checked against an automation's own effect. That's the
@@ -22959,6 +23299,64 @@ function matches(rule, prev, next, created) {
22959
23299
  default: return false;
22960
23300
  }
22961
23301
  }
23302
+ /** One rule's actions on one task, in order. */
23303
+ function runActions(actions, start, ctx) {
23304
+ let task = start;
23305
+ const comments = [];
23306
+ for (const action of actions) switch (action.type) {
23307
+ case "set_status": {
23308
+ if (!action.status) break;
23309
+ const wasDone = task.status === "done";
23310
+ const completedAt = action.status === "done" ? task.completedAt ?? ctx.now : wasDone ? null : task.completedAt;
23311
+ task = {
23312
+ ...task,
23313
+ status: action.status,
23314
+ completedAt
23315
+ };
23316
+ break;
23317
+ }
23318
+ case "set_priority":
23319
+ if (action.priority) task = {
23320
+ ...task,
23321
+ priority: action.priority
23322
+ };
23323
+ break;
23324
+ case "set_assignee":
23325
+ task = {
23326
+ ...task,
23327
+ assigneeId: action.assigneeId ?? null
23328
+ };
23329
+ break;
23330
+ case "set_due": {
23331
+ const n = Math.min(365, Math.max(0, Math.round(action.inDays ?? 0)));
23332
+ task = {
23333
+ ...task,
23334
+ dueDate: dayAfter(ctx.today, n)
23335
+ };
23336
+ break;
23337
+ }
23338
+ case "add_follower": {
23339
+ const id = action.personId;
23340
+ if (!id || id === task.assigneeId || task.followerIds?.includes(id)) break;
23341
+ task = {
23342
+ ...task,
23343
+ followerIds: [...task.followerIds ?? [], id]
23344
+ };
23345
+ break;
23346
+ }
23347
+ case "add_comment": if (action.commentBody?.trim()) comments.push({
23348
+ taskId: task.id,
23349
+ authorId: ctx.authorId,
23350
+ body: action.commentBody.trim(),
23351
+ createdAt: ctx.now,
23352
+ mentionedIds: []
23353
+ });
23354
+ }
23355
+ return {
23356
+ task,
23357
+ comments
23358
+ };
23359
+ }
22962
23360
  /**
22963
23361
  * Applies every enabled rule on `project` whose trigger matches this one
22964
23362
  * mutation (`prev` -> `next`, or `prev` undefined for a brand-new task) and
@@ -22969,49 +23367,32 @@ function matches(rule, prev, next, created) {
22969
23367
  function applyAutomations(project, prev, next, opts) {
22970
23368
  const rules = project?.automations ?? [];
22971
23369
  const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
23370
+ const today = opts.now ? opts.now.slice(0, 10) : todayISO();
23371
+ const ctx = {
23372
+ authorId: opts.currentUserId,
23373
+ now,
23374
+ today
23375
+ };
22972
23376
  let task = next;
22973
23377
  const comments = [];
22974
23378
  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
- }
23379
+ if (!matches(rule, prev, next, opts.created) || !meetsCondition(rule, next)) continue;
23380
+ const ran = runActions(rule.actions, task, ctx);
23381
+ task = ran.task;
23382
+ comments.push(...ran.comments);
23008
23383
  }
23009
23384
  return {
23010
23385
  task,
23011
23386
  comments
23012
23387
  };
23013
23388
  }
23014
- var init_automations = __esmMin((() => {}));
23389
+ var dayAfter;
23390
+ var init_automations = __esmMin((() => {
23391
+ init_date_fns();
23392
+ init_date_text();
23393
+ init_format();
23394
+ dayAfter = (day, n) => format(addDays(parseIso(day), n), "yyyy-MM-dd");
23395
+ }));
23015
23396
 
23016
23397
  // src/lib/focus.ts
23017
23398
  function focusIdsOf(data, personId, date = todayISO()) {
@@ -24499,7 +24880,7 @@ function pickCollections(doc, keys) {
24499
24880
  function emptyRows() {
24500
24881
  return Object.fromEntries(ROW_COLLECTIONS.map((k) => [k, []]));
24501
24882
  }
24502
- /** A collection as rows; `spendEntries`, `reflections`, `priorityRules` and `workCalendar` may be missing from older documents. */
24883
+ /** A collection as rows; `spendEntries`, `reflections`, `priorityRules`, `workCalendar` and `dashboards` may be missing from older documents. */
24503
24884
  function rowsOf(doc, key) {
24504
24885
  if (isKeyed(key)) return KEYED[key].rows(doc[key]);
24505
24886
  return doc[key] ?? [];
@@ -24584,7 +24965,8 @@ var init_changes = __esmMin((() => {
24584
24965
  "spendEntries",
24585
24966
  "reflections",
24586
24967
  "priorityRules",
24587
- "workCalendar"
24968
+ "workCalendar",
24969
+ "dashboards"
24588
24970
  ];
24589
24971
  ROW_COLLECTIONS = [
24590
24972
  "tasks",
@@ -24605,7 +24987,8 @@ var init_changes = __esmMin((() => {
24605
24987
  "ropes",
24606
24988
  "readPingIds",
24607
24989
  "priorityRules",
24608
- "workCalendar"
24990
+ "workCalendar",
24991
+ "dashboards"
24609
24992
  ];
24610
24993
  KEYED = {
24611
24994
  boardConfigs: {
@@ -28404,7 +28787,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
28404
28787
  if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
28405
28788
  label: "Any priority",
28406
28789
  value: "any"
28407
- }, ...PRIORITIES$1.map((p) => ({
28790
+ }, ...PRIORITIES$2.map((p) => ({
28408
28791
  label: PRIORITY_LABEL[p],
28409
28792
  value: p
28410
28793
  }))], (priority) => setFilter((f) => ({
@@ -29024,13 +29407,18 @@ const TRIGGER_LABEL = {
29024
29407
  task_created: "A task is created",
29025
29408
  status_changed_to: "Status is set to…",
29026
29409
  priority_changed_to: "Priority is set to…",
29027
- assigned: "A task is assigned to anyone"
29410
+ assigned: "A task is assigned to anyone",
29411
+ due_soon: "A task is due soon",
29412
+ overdue: "A task becomes overdue",
29413
+ untouched: "A task sits untouched"
29028
29414
  };
29029
29415
  const ACTION_LABEL = {
29030
29416
  set_status: "Set status to…",
29031
29417
  set_priority: "Set priority to…",
29032
29418
  set_assignee: "Assign to…",
29033
- add_comment: "Post a comment"
29419
+ add_comment: "Post a comment",
29420
+ set_due: "Move the due date",
29421
+ add_follower: "Add a follower"
29034
29422
  };
29035
29423
  function describeAction(a, peopleName) {
29036
29424
  switch (a.type) {
@@ -29038,18 +29426,36 @@ function describeAction(a, peopleName) {
29038
29426
  case "set_priority": return `set priority to ${a.priority ? PRIORITY_LABEL[a.priority] : "…"}`;
29039
29427
  case "set_assignee": return `assign to ${peopleName(a.assigneeId)}`;
29040
29428
  case "add_comment": return `post “${a.commentBody ?? ""}”`;
29429
+ case "set_due": {
29430
+ const n = a.inDays ?? 0;
29431
+ return n === 0 ? "set the due date to that day" : `set the due date ${n} ${n === 1 ? "day" : "days"} out`;
29432
+ }
29433
+ case "add_follower": return `add ${peopleName(a.personId)} as a follower`;
29041
29434
  }
29042
29435
  }
29436
+ const days$1 = (n) => `${n} ${n === 1 ? "day" : "days"}`;
29043
29437
  function describeTrigger(rule) {
29044
29438
  const t = rule.trigger;
29045
29439
  if (t.type === "status_changed_to") return `When status is set to ${t.status ? TASK_STATUS_LABEL[t.status] : "…"}`;
29046
29440
  if (t.type === "priority_changed_to") return `When priority is set to ${t.priority ? PRIORITY_LABEL[t.priority] : "…"}`;
29441
+ if (t.type === "due_soon") return `When a task is due within ${days$1(t.days ?? 2)}`;
29442
+ if (t.type === "untouched") return `When nobody touches a task for ${days$1(t.days ?? 7)}`;
29047
29443
  return `When ${TRIGGER_LABEL[t.type].toLowerCase()}`;
29048
29444
  }
29445
+ /** The "only if" line: "only High priority, owned by Ana", or "" when there's none. */
29446
+ function describeCondition(rule, peopleName) {
29447
+ const c = rule.onlyIf;
29448
+ if (!c) return "";
29449
+ const parts = [];
29450
+ if (c.priority) parts.push(`${PRIORITY_LABEL[c.priority]} priority`);
29451
+ if (c.assignee) parts.push(c.assignee === "nobody" ? "with no owner" : `owned by ${peopleName(c.assignee)}`);
29452
+ return parts.length ? `only ${parts.join(", ")}` : "";
29453
+ }
29049
29454
  /** "When status is set to Done: set priority to Low, post “Shipped”". */
29050
29455
  function describeRule(rule, peopleName) {
29051
29456
  const did = rule.actions.map((a) => describeAction(a, peopleName)).join(", ");
29052
- return `${describeTrigger(rule)}: ${did || "no actions"}`;
29457
+ const only = describeCondition(rule, peopleName);
29458
+ return `${describeTrigger(rule)}${only ? ` (${only})` : ""}: ${did || "no actions"}`;
29053
29459
  }
29054
29460
  function defaultAction(type) {
29055
29461
  if (type === "set_status") return {
@@ -29064,6 +29470,14 @@ function defaultAction(type) {
29064
29470
  type,
29065
29471
  assigneeId: null
29066
29472
  };
29473
+ if (type === "set_due") return {
29474
+ type,
29475
+ inDays: 3
29476
+ };
29477
+ if (type === "add_follower") return {
29478
+ type,
29479
+ personId: null
29480
+ };
29067
29481
  return {
29068
29482
  type,
29069
29483
  commentBody: ""
@@ -29103,6 +29517,7 @@ function readFieldValue(def, text) {
29103
29517
  * starts from, each column (name, shown, cap, place), lanes and what cards
29104
29518
  * show, the project's own fields and its automations. b on a project opens it.
29105
29519
  */
29520
+ init_automations();
29106
29521
  init_board();
29107
29522
  init_swatches();
29108
29523
  const LANES = [
@@ -29137,7 +29552,7 @@ const statusChoices = () => TASK_COLUMNS.map((s) => ({
29137
29552
  label: TASK_STATUS_LABEL[s],
29138
29553
  value: s
29139
29554
  }));
29140
- const priorityChoices = () => PRIORITIES$1.map((p) => ({
29555
+ const priorityChoices = () => PRIORITIES$2.map((p) => ({
29141
29556
  label: PRIORITY_LABEL[p],
29142
29557
  value: p
29143
29558
  }));
@@ -29391,6 +29806,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29391
29806
  type,
29392
29807
  priority: p
29393
29808
  }));
29809
+ if (type === "due_soon" || type === "untouched") return go.ask(type === "due_soon" ? "Due within how many days?" : "Untouched for how many days?", (text) => {
29810
+ const n = Math.round(Number(text.trim()));
29811
+ if (!Number.isFinite(n) || n < 1) return;
29812
+ askAction({
29813
+ type,
29814
+ days: Math.min(60, n)
29815
+ });
29816
+ });
29394
29817
  askAction({ type });
29395
29818
  }
29396
29819
  function askAction(trigger) {
@@ -29424,6 +29847,21 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29424
29847
  ...defaultAction(type),
29425
29848
  assigneeId: pid
29426
29849
  }));
29850
+ if (type === "set_due") return go.ask("Set the due date how many days out?", (text) => {
29851
+ const n = Math.round(Number(text.trim()));
29852
+ if (Number.isFinite(n) && n >= 0) add({
29853
+ type,
29854
+ inDays: Math.min(365, n)
29855
+ });
29856
+ });
29857
+ if (type === "add_follower") return go.pick("Add as a follower", people.map((p) => ({
29858
+ label: p.name,
29859
+ value: p.id,
29860
+ hint: p.role
29861
+ })), (pid) => add({
29862
+ type,
29863
+ personId: pid
29864
+ }));
29427
29865
  go.ask("Comment to post", (text) => text.trim() && add({
29428
29866
  type,
29429
29867
  commentBody: text.trim()
@@ -29822,11 +30260,11 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
29822
30260
  });
29823
30261
  } else if (f === "priority") editor = /* @__PURE__ */ jsx(Picker, {
29824
30262
  title: "Priority",
29825
- choices: PRIORITIES$1.map((p) => ({
30263
+ choices: PRIORITIES$2.map((p) => ({
29826
30264
  label: PRIORITY_LABEL[p],
29827
30265
  value: p
29828
30266
  })),
29829
- initial: PRIORITIES$1.indexOf(task.priority),
30267
+ initial: PRIORITIES$2.indexOf(task.priority),
29830
30268
  onCancel: done,
29831
30269
  onPick: (p) => {
29832
30270
  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.80.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",