@harborppm/cli 0.75.0 → 0.77.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
@@ -128,10 +128,10 @@ function timeAgo(date, now = clockNow()) {
128
128
  const words = `${n} ${unit}${n === 1 ? "" : "s"}`;
129
129
  return diff > 0 ? `in ${words}` : `${words} ago`;
130
130
  }
131
- var MONTHS, DAYS$1, pad$1, TOKENS, TOKEN, ISO, MINUTE;
131
+ var MONTHS$1, DAYS$2, pad$1, TOKENS, TOKEN, ISO, MINUTE;
132
132
  var init_date_text = __esmMin((() => {
133
133
  init_clock();
134
- MONTHS = [
134
+ MONTHS$1 = [
135
135
  "January",
136
136
  "February",
137
137
  "March",
@@ -145,7 +145,7 @@ var init_date_text = __esmMin((() => {
145
145
  "November",
146
146
  "December"
147
147
  ];
148
- DAYS$1 = [
148
+ DAYS$2 = [
149
149
  "Sunday",
150
150
  "Monday",
151
151
  "Tuesday",
@@ -157,13 +157,13 @@ var init_date_text = __esmMin((() => {
157
157
  pad$1 = (n, width = 2) => String(n).padStart(width, "0");
158
158
  TOKENS = {
159
159
  yyyy: (d) => pad$1(d.getFullYear(), 4),
160
- MMMM: (d) => MONTHS[d.getMonth()],
161
- MMM: (d) => MONTHS[d.getMonth()].slice(0, 3),
160
+ MMMM: (d) => MONTHS$1[d.getMonth()],
161
+ MMM: (d) => MONTHS$1[d.getMonth()].slice(0, 3),
162
162
  MM: (d) => pad$1(d.getMonth() + 1),
163
163
  dd: (d) => pad$1(d.getDate()),
164
164
  d: (d) => String(d.getDate()),
165
- EEEE: (d) => DAYS$1[d.getDay()],
166
- EEE: (d) => DAYS$1[d.getDay()].slice(0, 3)
165
+ EEEE: (d) => DAYS$2[d.getDay()],
166
+ EEE: (d) => DAYS$2[d.getDay()].slice(0, 3)
167
167
  };
168
168
  TOKEN = /yyyy|MMMM|MMM|MM|dd|d|EEEE|EEE/g;
169
169
  ISO = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?(Z|[+-]\d{2}(?::?\d{2})?)?)?$/;
@@ -2270,11 +2270,11 @@ function easter(year) {
2270
2270
  function nth(year, month, dow, n) {
2271
2271
  if (n > 0) {
2272
2272
  const first = iso$4(year, month, 1);
2273
- const shift = (dow - weekday(first) + 7) % 7;
2273
+ const shift = (dow - weekday$1(first) + 7) % 7;
2274
2274
  return plus$1(first, shift + (n - 1) * 7);
2275
2275
  }
2276
2276
  const last = iso$4(year, month + 1, 0);
2277
- return plus$1(last, -((weekday(last) - dow + 7) % 7));
2277
+ return plus$1(last, -((weekday$1(last) - dow + 7) % 7));
2278
2278
  }
2279
2279
  /** A country's public holidays in a year, by the day they're taken, oldest first. */
2280
2280
  function holidaysIn(country, year) {
@@ -2290,7 +2290,7 @@ function holidaysIn(country, year) {
2290
2290
  const out = [];
2291
2291
  for (const h of days) {
2292
2292
  let date = h.date;
2293
- const wd = weekday(date);
2293
+ const wd = weekday$1(date);
2294
2294
  if (h.move === "us" && (wd === 0 || wd === 6)) date = plus$1(date, wd === 6 ? -1 : 1);
2295
2295
  else if (h.move === "next" && (wd === 0 || wd === 6)) {
2296
2296
  date = plus$1(date, 1);
@@ -2389,19 +2389,19 @@ function harborHolidays(data, from, to) {
2389
2389
  };
2390
2390
  });
2391
2391
  }
2392
- var iso$4, weekday, plus$1, isWeekend, onOrBefore, fixed, fromEaster, MATARIKI, RULES, cache, PLAIN_CALENDAR;
2392
+ var iso$4, weekday$1, plus$1, isWeekend, onOrBefore, fixed, fromEaster, MATARIKI, RULES, cache, PLAIN_CALENDAR;
2393
2393
  var init_holidays = __esmMin((() => {
2394
2394
  init_date_fns();
2395
2395
  init_format();
2396
2396
  init_countries();
2397
2397
  iso$4 = (y, m, d) => todayISO(new Date(y, m - 1, d, 12));
2398
- weekday = (day) => (/* @__PURE__ */ new Date(`${day}T12:00:00`)).getDay();
2398
+ weekday$1 = (day) => (/* @__PURE__ */ new Date(`${day}T12:00:00`)).getDay();
2399
2399
  plus$1 = (day, n) => todayISO(addDays(/* @__PURE__ */ new Date(`${day}T12:00:00`), n));
2400
2400
  isWeekend = (day) => {
2401
- const wd = weekday(day);
2401
+ const wd = weekday$1(day);
2402
2402
  return wd === 0 || wd === 6;
2403
2403
  };
2404
- onOrBefore = (day, dow) => plus$1(day, -((weekday(day) - dow + 7) % 7));
2404
+ onOrBefore = (day, dow) => plus$1(day, -((weekday$1(day) - dow + 7) % 7));
2405
2405
  fixed = (m, d) => (y) => iso$4(y, m, d);
2406
2406
  fromEaster = (n) => (y) => plus$1(easter(y), n);
2407
2407
  MATARIKI = {
@@ -2499,7 +2499,7 @@ var init_holidays = __esmMin((() => {
2499
2499
  fixed(1, 1),
2500
2500
  "next"
2501
2501
  ],
2502
- ["St Brigid's Day", (y) => weekday(iso$4(y, 2, 1)) === 5 ? iso$4(y, 2, 1) : nth(y, 2, 1, 1)],
2502
+ ["St Brigid's Day", (y) => weekday$1(iso$4(y, 2, 1)) === 5 ? iso$4(y, 2, 1) : nth(y, 2, 1, 1)],
2503
2503
  [
2504
2504
  "St Patrick's Day",
2505
2505
  fixed(3, 17),
@@ -2611,7 +2611,7 @@ var init_holidays = __esmMin((() => {
2611
2611
  NL: [
2612
2612
  ["Nieuwjaarsdag", fixed(1, 1)],
2613
2613
  ["Tweede paasdag", fromEaster(1)],
2614
- ["Koningsdag", (y) => weekday(iso$4(y, 4, 27)) === 0 ? iso$4(y, 4, 26) : iso$4(y, 4, 27)],
2614
+ ["Koningsdag", (y) => weekday$1(iso$4(y, 4, 27)) === 0 ? iso$4(y, 4, 26) : iso$4(y, 4, 27)],
2615
2615
  ["Hemelvaartsdag", fromEaster(39)],
2616
2616
  ["Tweede pinksterdag", fromEaster(50)],
2617
2617
  ["Eerste kerstdag", fixed(12, 25)],
@@ -3753,6 +3753,359 @@ function applyReckoning(actions, projectId, r, startDate) {
3753
3753
  };
3754
3754
  }
3755
3755
 
3756
+ // src/lib/plain-words.ts
3757
+ const DAYS$1 = [
3758
+ "sunday",
3759
+ "monday",
3760
+ "tuesday",
3761
+ "wednesday",
3762
+ "thursday",
3763
+ "friday",
3764
+ "saturday"
3765
+ ];
3766
+ const MONTHS = [
3767
+ "january",
3768
+ "february",
3769
+ "march",
3770
+ "april",
3771
+ "may",
3772
+ "june",
3773
+ "july",
3774
+ "august",
3775
+ "september",
3776
+ "october",
3777
+ "november",
3778
+ "december"
3779
+ ];
3780
+ const NUMBER_WORDS = {
3781
+ a: 1,
3782
+ an: 1,
3783
+ one: 1,
3784
+ two: 2,
3785
+ three: 3,
3786
+ four: 4,
3787
+ five: 5,
3788
+ six: 6,
3789
+ seven: 7,
3790
+ eight: 8,
3791
+ nine: 9,
3792
+ ten: 10
3793
+ };
3794
+ const loose = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
3795
+ function shift(iso, days) {
3796
+ const d = /* @__PURE__ */ new Date(`${iso}T12:00:00Z`);
3797
+ d.setUTCDate(d.getUTCDate() + days);
3798
+ return d.toISOString().slice(0, 10);
3799
+ }
3800
+ function weekday(iso) {
3801
+ return (/* @__PURE__ */ new Date(`${iso}T12:00:00Z`)).getUTCDay();
3802
+ }
3803
+ function dayOf$1(y, m, d) {
3804
+ const date = new Date(Date.UTC(y, m - 1, d, 12));
3805
+ if (date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
3806
+ return date.toISOString().slice(0, 10);
3807
+ }
3808
+ /** A month and day with no year: this year's, or next year's once it has passed. */
3809
+ function nextMonthDay(today, m, d) {
3810
+ const year = Number(today.slice(0, 4));
3811
+ const thisYear = dayOf$1(year, m, d);
3812
+ if (!thisYear) return null;
3813
+ return thisYear >= today ? thisYear : dayOf$1(year + 1, m, d);
3814
+ }
3815
+ const monthIndex = (word) => {
3816
+ const w = word.toLowerCase().replace(/\.$/, "");
3817
+ if (w.length < 3) return -1;
3818
+ return MONTHS.findIndex((m) => m.startsWith(w) && (w.length >= 3 || m === w));
3819
+ };
3820
+ const DAY_WORD = "(?:sun|mon|tues?|wed(?:nes)?|thu(?:rs?)?|fri|sat(?:ur)?)(?:day)?";
3821
+ const MONTH_WORD = "(?:jan|feb|mar|apr|may|jun|jul|aug|sept?|oct|nov|dec)[a-z]*\\.?";
3822
+ /** Every way of saying a day, longest first so "next friday" wins over "friday". */
3823
+ const WHEN = [
3824
+ "today",
3825
+ "tonight",
3826
+ "tomorrow",
3827
+ "tmrw",
3828
+ "the day after tomorrow",
3829
+ "end of (?:the )?week",
3830
+ "this week",
3831
+ "next week",
3832
+ "end of (?:the )?month",
3833
+ "next month",
3834
+ `in (?:\\d{1,2}|a|an|one|two|three|four|five|six|seven|eight|nine|ten) (?:days?|weeks?)`,
3835
+ `(?:this |next |on )?${DAY_WORD}`,
3836
+ `${MONTH_WORD} \\d{1,2}(?:st|nd|rd|th)?(?:,? \\d{4})?`,
3837
+ `\\d{1,2}(?:st|nd|rd|th)? (?:of )?${MONTH_WORD}(?:,? \\d{4})?`,
3838
+ "\\d{4}-\\d{1,2}-\\d{1,2}",
3839
+ "\\d{1,2}/\\d{1,2}(?:/\\d{2,4})?"
3840
+ ].join("|");
3841
+ const LEAD$1 = "(?:by|due|due on|due by|on|before|until|till)";
3842
+ /** The day a phrase names, or null when it names none. */
3843
+ function dayFromWords(phrase, today) {
3844
+ const p = phrase.toLowerCase().replace(/\s+/g, " ").replace(/^on /, "").trim();
3845
+ if (p === "today" || p === "tonight") return today;
3846
+ if (p === "tomorrow" || p === "tmrw") return shift(today, 1);
3847
+ if (p === "the day after tomorrow") return shift(today, 2);
3848
+ const dow = weekday(today);
3849
+ if (/^end of (the )?week$/.test(p) || p === "this week") return shift(today, (5 - dow + 7) % 7);
3850
+ if (p === "next week") return shift(today, (1 - dow + 7) % 7 || 7);
3851
+ if (p === "next month") {
3852
+ const [y, m] = [Number(today.slice(0, 4)), Number(today.slice(5, 7))];
3853
+ return m === 12 ? dayOf$1(y + 1, 1, 1) : dayOf$1(y, m + 1, 1);
3854
+ }
3855
+ if (/^end of (the )?month$/.test(p)) {
3856
+ const [y, m] = [Number(today.slice(0, 4)), Number(today.slice(5, 7))];
3857
+ return shift(m === 12 ? dayOf$1(y + 1, 1, 1) : dayOf$1(y, m + 1, 1), -1);
3858
+ }
3859
+ let m = p.match(/^in (\w+) (day|week)s?$/);
3860
+ if (m) {
3861
+ const n = /^\d+$/.test(m[1]) ? Number(m[1]) : NUMBER_WORDS[m[1]];
3862
+ if (n == null || n > 366) return null;
3863
+ return shift(today, m[2] === "week" ? n * 7 : n);
3864
+ }
3865
+ m = p.match(/^(this |next )?([a-z]+)$/);
3866
+ if (m) {
3867
+ const target = DAYS$1.findIndex((d) => d.startsWith(m[2].slice(0, 3)) && m[2].length >= 3);
3868
+ if (target >= 0 && new RegExp(`^${DAY_WORD}$`).test(m[2])) {
3869
+ const ahead = (target - dow + 7) % 7 || 7;
3870
+ return shift(today, m[1] === "next " ? ahead + 7 : ahead);
3871
+ }
3872
+ }
3873
+ m = p.match(/^([a-z]+\.?) (\d{1,2})(?:st|nd|rd|th)?(?:,? (\d{4}))?$/);
3874
+ if (m && monthIndex(m[1]) >= 0) {
3875
+ const month = monthIndex(m[1]) + 1;
3876
+ return m[3] ? dayOf$1(Number(m[3]), month, Number(m[2])) : nextMonthDay(today, month, Number(m[2]));
3877
+ }
3878
+ m = p.match(/^(\d{1,2})(?:st|nd|rd|th)? (?:of )?([a-z]+\.?)(?:,? (\d{4}))?$/);
3879
+ if (m && monthIndex(m[2]) >= 0) {
3880
+ const month = monthIndex(m[2]) + 1;
3881
+ return m[3] ? dayOf$1(Number(m[3]), month, Number(m[1])) : nextMonthDay(today, month, Number(m[1]));
3882
+ }
3883
+ m = p.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
3884
+ if (m) return dayOf$1(Number(m[1]), Number(m[2]), Number(m[3]));
3885
+ m = p.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
3886
+ if (m) {
3887
+ if (!m[3]) return nextMonthDay(today, Number(m[1]), Number(m[2]));
3888
+ return dayOf$1(Number(m[3]) < 100 ? 2e3 + Number(m[3]) : Number(m[3]), Number(m[1]), Number(m[2]));
3889
+ }
3890
+ return null;
3891
+ }
3892
+ const PRIORITY_WORDS = {
3893
+ urgent: "urgent",
3894
+ asap: "urgent",
3895
+ top: "urgent",
3896
+ p0: "urgent",
3897
+ high: "high",
3898
+ p1: "high",
3899
+ medium: "medium",
3900
+ normal: "medium",
3901
+ p2: "medium",
3902
+ low: "low",
3903
+ p3: "low"
3904
+ };
3905
+ /** Removes found spans from the text, tidying the spaces and commas they leave. */
3906
+ function without(text, spans) {
3907
+ let out = text;
3908
+ for (const s of [...spans].sort((a, b) => b.start - a.start)) out = `${out.slice(0, s.start)} ${out.slice(s.end)}`;
3909
+ return out.replace(/\s+([,.;:!?])/g, "$1").replace(/[,;:]+([,.;:!?])/g, "$1").replace(/\s{2,}/g, " ").replace(/(?:[\s,;:–—-]+)$/u, "").replace(/^[\s,;:]+/, "").trim();
3910
+ }
3911
+ /** What a new task's words say, and the title they leave. */
3912
+ function readTaskWords(input, ctx) {
3913
+ const skip = ctx.skip ?? /* @__PURE__ */ new Set();
3914
+ const text = input;
3915
+ const taken = [];
3916
+ const free = (start, end) => !taken.some((t) => start < t.end && end > t.start);
3917
+ let priority = null;
3918
+ if (!skip.has("priority")) for (const re of [
3919
+ /(?:^|\s)!(urgent|asap|high|medium|normal|low|p[0-3])\b/gi,
3920
+ /(?:^|[\s,])(urgent|top|high|medium|normal|low) priority\b/gi,
3921
+ /(?:^|[\s,])priority (urgent|high|medium|normal|low|p[0-3])\b/gi,
3922
+ /(?:^|[\s,])(urgent|asap)$/gi
3923
+ ]) {
3924
+ const m = re.exec(text);
3925
+ if (m) {
3926
+ const lead = m[0].length - m[0].trimStart().length;
3927
+ const start = m.index + lead + (m[0].trimStart().startsWith(",") ? 1 : 0);
3928
+ priority = {
3929
+ value: PRIORITY_WORDS[m[1].toLowerCase()],
3930
+ start,
3931
+ end: m.index + m[0].length
3932
+ };
3933
+ taken.push(priority);
3934
+ break;
3935
+ }
3936
+ }
3937
+ let project = null;
3938
+ if (!skip.has("project")) for (const m of text.matchAll(/(?:^|\s)#([\p{L}\p{N}_-]+)/gu)) {
3939
+ const key = loose(m[1]);
3940
+ if (!key) continue;
3941
+ const exact = ctx.projects.filter((p) => loose(p.name) === key);
3942
+ const begins = ctx.projects.filter((p) => loose(p.name).startsWith(key));
3943
+ const hit = exact.length === 1 ? exact[0] : begins.length === 1 ? begins[0] : null;
3944
+ if (!hit) continue;
3945
+ const start = m.index + (m[0].length - m[0].trimStart().length);
3946
+ project = {
3947
+ value: hit.id,
3948
+ start,
3949
+ end: m.index + m[0].length
3950
+ };
3951
+ taken.push(project);
3952
+ break;
3953
+ }
3954
+ let owner = null;
3955
+ if (!skip.has("owner")) {
3956
+ const byKey = (key) => {
3957
+ const full = ctx.people.filter((p) => loose(p.name) === key);
3958
+ if (full.length === 1) return full[0];
3959
+ const first = ctx.people.filter((p) => loose(p.name.split(/\s+/)[0] ?? "") === key);
3960
+ if (first.length === 1) return first[0];
3961
+ const begins = ctx.people.filter((p) => loose(p.name).startsWith(key));
3962
+ return key.length >= 3 && begins.length === 1 ? begins[0] : null;
3963
+ };
3964
+ for (const m of text.matchAll(/(?:^|\s)@([\p{L}\p{N}._-]+)/gu)) {
3965
+ const hit = byKey(loose(m[1]));
3966
+ if (!hit) continue;
3967
+ const start = m.index + (m[0].length - m[0].trimStart().length);
3968
+ if (!free(start, m.index + m[0].length)) continue;
3969
+ owner = {
3970
+ value: hit.id,
3971
+ start,
3972
+ end: m.index + m[0].length
3973
+ };
3974
+ break;
3975
+ }
3976
+ if (!owner) for (const m of text.matchAll(/(?:^|[\s,])(?:assign(?:ed)? (?:it )?to|owner(?: is)?:?) (\p{L}+(?: \p{L}+)?)/giu)) {
3977
+ const words = m[1].split(" ");
3978
+ const two = words.length === 2 ? byKey(loose(m[1])) : null;
3979
+ const one = byKey(loose(words[0]));
3980
+ const hit = two ?? one;
3981
+ if (!hit) continue;
3982
+ const lead = m[0].length - m[0].replace(/^[\s,]+/, "").length;
3983
+ const start = m.index + lead;
3984
+ const end = two ? m.index + m[0].length : m.index + m[0].length - (words.length === 2 ? words[1].length + 1 : 0);
3985
+ if (!free(start, end)) continue;
3986
+ owner = {
3987
+ value: hit.id,
3988
+ start,
3989
+ end
3990
+ };
3991
+ break;
3992
+ }
3993
+ if (owner) taken.push(owner);
3994
+ }
3995
+ let due = null;
3996
+ if (!skip.has("due")) {
3997
+ const led = new RegExp(`(?:^|[\\s,])(${LEAD$1} (${WHEN}))(?=$|[\\s,.;:!?])`, "gi");
3998
+ for (const m of text.matchAll(led)) {
3999
+ const day = dayFromWords(m[2], ctx.today);
4000
+ if (!day) continue;
4001
+ const start = m.index + m[0].indexOf(m[1]);
4002
+ const end = start + m[1].length;
4003
+ if (!free(start, end)) continue;
4004
+ due = {
4005
+ value: day,
4006
+ start,
4007
+ end
4008
+ };
4009
+ break;
4010
+ }
4011
+ if (!due) {
4012
+ const rest = without(text, taken);
4013
+ const tail = new RegExp(`(?:^|[\\s,])(${WHEN})[.!]?$`, "i").exec(rest);
4014
+ const short = tail && /^(?:sun|mon|tue|wed|thu|fri|sat)$/i.test(tail[1]);
4015
+ const said = tail && /\b(?:to|for|from|after|with|since)$/i.test(rest.slice(0, tail.index).trimEnd());
4016
+ if (tail && !short && !said && rest.trim().length > tail[1].length) {
4017
+ const day = dayFromWords(tail[1], ctx.today);
4018
+ if (day) {
4019
+ const at = text.toLowerCase().lastIndexOf(tail[1].toLowerCase());
4020
+ if (at >= 0 && free(at, at + tail[1].length)) due = {
4021
+ value: day,
4022
+ start: at,
4023
+ end: at + tail[1].length
4024
+ };
4025
+ }
4026
+ }
4027
+ }
4028
+ if (due) taken.push(due);
4029
+ }
4030
+ const span = (f) => f ? text.slice(f.start, f.end).trim() : "";
4031
+ let title = without(text, taken);
4032
+ const first = text.search(/\S/);
4033
+ if (taken.some((t) => t.start === first)) title = title.charAt(0).toUpperCase() + title.slice(1);
4034
+ return {
4035
+ title,
4036
+ due: due ? {
4037
+ value: due.value,
4038
+ text: span(due)
4039
+ } : null,
4040
+ owner: owner ? {
4041
+ value: owner.value,
4042
+ text: span(owner)
4043
+ } : null,
4044
+ priority: priority ? {
4045
+ value: priority.value,
4046
+ text: span(priority)
4047
+ } : null,
4048
+ project: project ? {
4049
+ value: project.value,
4050
+ text: span(project)
4051
+ } : null
4052
+ };
4053
+ }
4054
+
4055
+ // src/lib/swatches.ts
4056
+ var SWATCHES, HEALTH_LABEL$1, TASK_COLUMNS, TASK_STATUS_LABEL, PRIORITY_LABEL, CONSTRAINT_KIND_LABEL, CONSTRAINT_KIND_HINT;
4057
+ var init_swatches = __esmMin((() => {
4058
+ SWATCHES = [
4059
+ "pine",
4060
+ "clay",
4061
+ "sage",
4062
+ "sand",
4063
+ "slate",
4064
+ "sea",
4065
+ "rust",
4066
+ "moss"
4067
+ ];
4068
+ HEALTH_LABEL$1 = {
4069
+ "on-track": "On track",
4070
+ "at-risk": "At risk",
4071
+ "off-track": "Off track",
4072
+ paused: "Paused",
4073
+ done: "Done"
4074
+ };
4075
+ TASK_COLUMNS = [
4076
+ "backlog",
4077
+ "todo",
4078
+ "doing",
4079
+ "review",
4080
+ "done"
4081
+ ];
4082
+ TASK_STATUS_LABEL = {
4083
+ backlog: "Backlog",
4084
+ todo: "To do",
4085
+ doing: "In play",
4086
+ review: "Review",
4087
+ done: "Done"
4088
+ };
4089
+ PRIORITY_LABEL = {
4090
+ low: "Low",
4091
+ medium: "Medium",
4092
+ high: "High",
4093
+ urgent: "Urgent"
4094
+ };
4095
+ CONSTRAINT_KIND_LABEL = {
4096
+ resource: "A person",
4097
+ policy: "A policy",
4098
+ external: "Outside the building",
4099
+ collision: "A collision"
4100
+ };
4101
+ CONSTRAINT_KIND_HINT = {
4102
+ resource: "Someone whose calendar sets the date.",
4103
+ policy: "A rule that creates the wait.",
4104
+ external: "A vendor, a city, a review board.",
4105
+ collision: "Two things sharing one window."
4106
+ };
4107
+ }));
4108
+
3756
4109
  // src/lib/raid.ts
3757
4110
  /** Still needs someone's attention: not closed, resolved, decided or superseded. */
3758
4111
  function isActive(item) {
@@ -3963,67 +4316,13 @@ var init_raid = __esmMin((() => {
3963
4316
  ids = (v) => Array.isArray(v) ? [...new Set(v.filter((x) => typeof x === "string" && !!x))] : [];
3964
4317
  }));
3965
4318
 
3966
- // src/lib/swatches.ts
3967
- var SWATCHES, HEALTH_LABEL$1, TASK_COLUMNS, TASK_STATUS_LABEL, PRIORITY_LABEL, CONSTRAINT_KIND_LABEL, CONSTRAINT_KIND_HINT;
3968
- var init_swatches = __esmMin((() => {
3969
- SWATCHES = [
3970
- "pine",
3971
- "clay",
3972
- "sage",
3973
- "sand",
3974
- "slate",
3975
- "sea",
3976
- "rust",
3977
- "moss"
3978
- ];
3979
- HEALTH_LABEL$1 = {
3980
- "on-track": "On track",
3981
- "at-risk": "At risk",
3982
- "off-track": "Off track",
3983
- paused: "Paused",
3984
- done: "Done"
3985
- };
3986
- TASK_COLUMNS = [
3987
- "backlog",
3988
- "todo",
3989
- "doing",
3990
- "review",
3991
- "done"
3992
- ];
3993
- TASK_STATUS_LABEL = {
3994
- backlog: "Backlog",
3995
- todo: "To do",
3996
- doing: "In play",
3997
- review: "Review",
3998
- done: "Done"
3999
- };
4000
- PRIORITY_LABEL = {
4001
- low: "Low",
4002
- medium: "Medium",
4003
- high: "High",
4004
- urgent: "Urgent"
4005
- };
4006
- CONSTRAINT_KIND_LABEL = {
4007
- resource: "A person",
4008
- policy: "A policy",
4009
- external: "Outside the building",
4010
- collision: "A collision"
4011
- };
4012
- CONSTRAINT_KIND_HINT = {
4013
- resource: "Someone whose calendar sets the date.",
4014
- policy: "A rule that creates the wait.",
4015
- external: "A vendor, a city, a review board.",
4016
- collision: "Two things sharing one window."
4017
- };
4018
- }));
4019
-
4020
4319
  // src/tui/create.ts
4021
4320
  init_date_fns();
4022
4321
  init_format();
4322
+ init_swatches();
4023
4323
  init_indexes();
4024
4324
  init_date_text();
4025
4325
  init_raid();
4026
- init_swatches();
4027
4326
  const scale = (labels) => [
4028
4327
  1,
4029
4328
  2,
@@ -4064,6 +4363,28 @@ function planFromChoices(data, finished, name, programId) {
4064
4363
  ...rest.map((p) => choice(p, false))
4065
4364
  ];
4066
4365
  }
4366
+ /**
4367
+ * The terminal's new task box reads its title as the website's does:
4368
+ * "Book the venue @Quinn by Friday !high #Website" adds “Book the venue”
4369
+ * for Quinn, due Friday, high priority, in Website.
4370
+ */
4371
+ function readNewTask(text, data, today) {
4372
+ const words = readTaskWords(text, {
4373
+ today,
4374
+ people: data.people,
4375
+ projects: data.projects
4376
+ });
4377
+ const bits = [
4378
+ words.owner && (data.people.find((p) => p.id === words.owner.value)?.name ?? "someone"),
4379
+ words.due && `due ${longDate(words.due.value)}`,
4380
+ words.priority && `${PRIORITY_LABEL[words.priority.value].toLowerCase()} priority`,
4381
+ words.project && (data.projects.find((p) => p.id === words.project.value)?.name ?? "project")
4382
+ ].filter((b) => Boolean(b));
4383
+ return {
4384
+ words,
4385
+ line: bits.length && words.title ? [`Adds “${words.title}”`, ...bits].join(" · ") : null
4386
+ };
4387
+ }
4067
4388
  const CREATE_TITLE = {
4068
4389
  portfolio: "New portfolio",
4069
4390
  project: "New project",
@@ -6334,7 +6655,7 @@ applyTheme("dark");
6334
6655
  * from, the key hints along the bottom, health and priority marks.
6335
6656
  */
6336
6657
  /** One line of text. Enter keeps it, Esc leaves it as it was. */
6337
- function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel }) {
6658
+ function TextField({ label, initial = "", placeholder, hint, hintFor, onSubmit, onCancel }) {
6338
6659
  const [value, setShown] = useState(initial);
6339
6660
  const typed = useRef(initial);
6340
6661
  const setValue = (next) => {
@@ -6379,7 +6700,7 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
6379
6700
  /* @__PURE__ */ jsx(Text, {
6380
6701
  color: theme.muted,
6381
6702
  wrap: "truncate-end",
6382
- children: hint ?? "Enter to keep · Esc to leave it"
6703
+ children: hintFor?.(value) ?? hint ?? "Enter to keep · Esc to leave it"
6383
6704
  })
6384
6705
  ]
6385
6706
  });
@@ -12355,6 +12676,8 @@ function Splash({ opening }) {
12355
12676
 
12356
12677
  // src/tui/views/overlays.tsx
12357
12678
  init_search();
12679
+ init_clock();
12680
+ init_format();
12358
12681
  /** Find a task by key or words; Enter opens it. */
12359
12682
  function SearchBox({ width, onOpen, onClose }) {
12360
12683
  const tasks = useHarbor((s) => s.tasks);
@@ -12444,11 +12767,19 @@ function SearchBox({ width, onOpen, onClose }) {
12444
12767
  ]
12445
12768
  });
12446
12769
  }
12447
- /** Add a task: which project (when it isn't known), then its title. */
12770
+ /**
12771
+ * Add a task: which project (when it isn't known), then its title, read in
12772
+ * plain words as the website reads it (@name, by Friday, !high, #project).
12773
+ */
12448
12774
  function NewTask({ projectId, status, width, onDone, onFull }) {
12449
12775
  const { store } = useDeps();
12450
12776
  const projects = useHarbor((s) => s.projects);
12777
+ const people = useHarbor((s) => s.people);
12451
12778
  const me = useHarbor((s) => s.currentUserId);
12779
+ const read = (title) => readNewTask(title, {
12780
+ people,
12781
+ projects
12782
+ }, todayISO(clockNow()));
12452
12783
  const [project, setProject] = useState(() => projectId ?? soleOpenProject(projects));
12453
12784
  const chosen = projects.find((p) => p.id === project);
12454
12785
  if (!chosen) return /* @__PURE__ */ jsx(Box, {
@@ -12468,21 +12799,24 @@ function NewTask({ projectId, status, width, onDone, onFull }) {
12468
12799
  width,
12469
12800
  children: /* @__PURE__ */ jsx(TextField, {
12470
12801
  label: `New task in ${chosen.name}${status ? ` · ${STATUS_LABEL[status]}` : ""}`,
12471
- placeholder: "What needs doing?",
12802
+ placeholder: "What needs doing? Add @name, by Friday, #project",
12472
12803
  hint: `Enter adds it (yours, ${STATUS_LABEL[status ?? "todo"].toLowerCase()}) · Esc leaves it`,
12804
+ hintFor: (title) => read(title).line,
12473
12805
  onCancel: () => onDone(null),
12474
12806
  onSubmit: (title) => {
12475
12807
  if (!title.trim()) return onDone(null);
12476
- const full = roomFor(store, chosen.id, status ?? "todo");
12808
+ const { words } = read(title.trim());
12809
+ const projectId = words.project?.value ?? chosen.id;
12810
+ const full = roomFor(store, projectId, status ?? "todo");
12477
12811
  if (full) return onFull(full);
12478
12812
  onDone(store.getState().addTask({
12479
- projectId: chosen.id,
12480
- title: title.trim(),
12813
+ projectId,
12814
+ title: words.title || title.trim(),
12481
12815
  description: "",
12482
12816
  status: status ?? "todo",
12483
- priority: "medium",
12484
- assigneeId: me || null,
12485
- dueDate: null
12817
+ priority: words.priority?.value ?? "medium",
12818
+ assigneeId: words.owner?.value ?? (me || null),
12819
+ dueDate: words.due?.value ?? null
12486
12820
  }));
12487
12821
  }
12488
12822
  })
@@ -13298,6 +13632,36 @@ function applyDock(actions, projectId, changes) {
13298
13632
  return made;
13299
13633
  }
13300
13634
 
13635
+ // src/lib/time-limits.ts
13636
+ var MAX_ENTRY_MINUTES;
13637
+ var init_time_limits = __esmMin((() => {
13638
+ MAX_ENTRY_MINUTES = 1440;
13639
+ }));
13640
+
13641
+ // src/lib/time-log.ts
13642
+ /** "1h 30m", "45m", "12h". */
13643
+ function formatMinutes(total) {
13644
+ const n = Math.max(0, Math.round(total));
13645
+ const h = Math.floor(n / 60);
13646
+ const m = n % 60;
13647
+ if (h === 0) return `${m}m`;
13648
+ return m === 0 ? `${h}h` : `${h}h ${m}m`;
13649
+ }
13650
+ /** Hours with up to two decimals, for spreadsheets: 90 → 1.5. */
13651
+ function hours(minutes) {
13652
+ return Math.round(minutes / 60 * 100) / 100;
13653
+ }
13654
+ /** All the time on one task. */
13655
+ function taskMinutes(task) {
13656
+ let n = 0;
13657
+ for (const e of task.time ?? []) n += e.minutes;
13658
+ return n;
13659
+ }
13660
+ /** A task's entries, newest day first. */
13661
+ function entriesNewestFirst(task) {
13662
+ return [...task.time ?? []].sort((a, b) => (b.on + b.at).localeCompare(a.on + a.at));
13663
+ }
13664
+
13301
13665
  // node_modules/zod/v4/core/util.js
13302
13666
  function getEnumValues(entries) {
13303
13667
  const numericValues = Object.values(entries).filter((v) => typeof v === "number");
@@ -18813,10 +19177,17 @@ function harborExport(data, harbor, now) {
18813
19177
  * through Import.
18814
19178
  */
18815
19179
  function allTasksCsv(data) {
19180
+ return tasksSheetCsv(data, data.tasks);
19181
+ }
19182
+ /**
19183
+ * These tasks, one row each, as `allTasksCsv` lays them out: the whole
19184
+ * harbor's, or a program's for a sheet link (src/lib/sheet-feed.ts).
19185
+ */
19186
+ function tasksSheetCsv(data, rows) {
18816
19187
  const projects = byId(data.projects);
18817
19188
  const programs = byId(data.programs);
18818
19189
  const people = byId(data.people);
18819
- const tasks = byId(data.tasks);
19190
+ const tasks = byId(rows);
18820
19191
  const keyOf = (t) => taskKey(t, projects.get(t.projectId)) ?? t.title;
18821
19192
  return toCsv([
18822
19193
  "Key",
@@ -18830,8 +19201,9 @@ function allTasksCsv(data) {
18830
19201
  "Done",
18831
19202
  "Blocked by",
18832
19203
  "Created",
18833
- "Description"
18834
- ], data.tasks.map((t) => {
19204
+ "Description",
19205
+ "Hours logged"
19206
+ ], rows.map((t) => {
18835
19207
  const project = projects.get(t.projectId);
18836
19208
  const program = project ? programs.get(project.programId) : void 0;
18837
19209
  return [
@@ -18846,7 +19218,8 @@ function allTasksCsv(data) {
18846
19218
  t.status === "done" ? "yes" : "no",
18847
19219
  blockerIds(t).map((id) => tasks.get(id)).filter((b) => Boolean(b)).map(keyOf).join("; "),
18848
19220
  t.createdAt?.slice(0, 10) ?? "",
18849
- t.description
19221
+ t.description,
19222
+ t.time?.length ? hours(taskMinutes(t)) : ""
18850
19223
  ];
18851
19224
  }));
18852
19225
  }
@@ -19501,6 +19874,16 @@ var init_fog = __esmMin((() => {
19501
19874
  }));
19502
19875
 
19503
19876
  // src/lib/mcp/tools.ts
19877
+ /**
19878
+ * What an agent can read about a harbor, as MCP tools. Each tool is a pure
19879
+ * function of one harbor's document (health already stamped) and who is
19880
+ * asking, so it is tested against the demo without a server. The route in
19881
+ * src/routes/api/mcp.ts signs the caller in, picks the harbor and loads it.
19882
+ *
19883
+ * Read only: nothing here changes a workspace. Answers carry ids so an agent
19884
+ * can ask about one thing next, and every list is capped and pageable, since
19885
+ * a harbor can hold thousands of tasks.
19886
+ */
19504
19887
  init_thanks();
19505
19888
  init_task_keys();
19506
19889
  init_brief();
@@ -19530,6 +19913,20 @@ init_passage();
19530
19913
  init_board();
19531
19914
  init_holidays();
19532
19915
  init_soundings();
19916
+ /** A task's logged time for get_task: the total and the latest entries. */
19917
+ function timeOut(t, people) {
19918
+ const entries = entriesNewestFirst(t);
19919
+ return {
19920
+ total: formatMinutes(taskMinutes(t)),
19921
+ minutes: taskMinutes(t),
19922
+ latest: entries.slice(0, 10).map((e) => ({
19923
+ on: e.on,
19924
+ by: nameOf$2(people, e.personId),
19925
+ time: formatMinutes(e.minutes),
19926
+ ...e.note ? { note: e.note } : {}
19927
+ }))
19928
+ };
19929
+ }
19533
19930
  /**
19534
19931
  * Where the website opens one task or project, switching to its harbor
19535
19932
  * first (src/routes/open.tsx). Nothing without an origin.
@@ -20229,7 +20626,7 @@ const HARBOR_TOOLS = [
20229
20626
  tool({
20230
20627
  name: "get_task",
20231
20628
  title: "Task details",
20232
- description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), and its url on the website." + DATA_NOTE,
20629
+ description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), and its url on the website." + DATA_NOTE,
20233
20630
  input: object({
20234
20631
  harbor: harborArg,
20235
20632
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -20278,6 +20675,7 @@ const HARBOR_TOOLS = [
20278
20675
  step: i.title,
20279
20676
  done: i.done
20280
20677
  })),
20678
+ ...t.time?.length ? { timeLogged: timeOut(t, people) } : {},
20281
20679
  waitingOn: blockersOf(t, data.tasks).map(brief),
20282
20680
  waitedOnBy: waiters.map(brief),
20283
20681
  ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
@@ -21340,10 +21738,11 @@ function viewerUpdate(state, partial) {
21340
21738
  }
21341
21739
  /**
21342
21740
  * Whether this saved harbor can't be changed from here: the person is a
21343
- * viewer, or its free trial has ended with nobody keeping it (keep.ts).
21741
+ * viewer, its free trial has ended with nobody keeping it (keep.ts), or it
21742
+ * was opened offline from this device's copy (`offlineCopy`).
21344
21743
  */
21345
21744
  function readOnlyHarbor(s) {
21346
- return s.cloud && (s.myRole === "viewer" || Boolean(s.keepPaused));
21745
+ return s.cloud && (s.myRole === "viewer" || Boolean(s.keepPaused) || Boolean(s.offlineCopy));
21347
21746
  }
21348
21747
  var VIEWER_LOCAL, documentKeys;
21349
21748
  var init_view_only = __esmMin((() => {
@@ -21798,6 +22197,7 @@ function sessionSlice({ set, get, write }) {
21798
22197
  }),
21799
22198
  setWorkspaceVersion: (workspaceVersion) => set({ workspaceVersion }),
21800
22199
  setPhase: (phase) => set({ phase }),
22200
+ setOfflineCopy: (offlineCopy) => set({ offlineCopy }),
21801
22201
  setMyRole: (myRole) => set({ myRole }),
21802
22202
  setHarborName: (harborName) => set({ harborName }),
21803
22203
  setKeep: (k) => set({
@@ -22867,6 +23267,37 @@ function tasksSlice({ set, get, write }) {
22867
23267
  checklist: (t.checklist ?? []).filter((i) => i.id !== itemId)
22868
23268
  } : t) });
22869
23269
  },
23270
+ logTime: (taskId, entry) => {
23271
+ const minutes = Math.round(entry.minutes);
23272
+ if (!Number.isFinite(minutes) || minutes < 1 || minutes > 1440) return null;
23273
+ const task = get().tasks.find((t) => t.id === taskId);
23274
+ if (!task || (task.time?.length ?? 0) >= 500) return null;
23275
+ const personId = entry.personId ?? get().currentUserId;
23276
+ if (!personId) return null;
23277
+ const id = uid("tm");
23278
+ const note = entry.note?.trim().slice(0, 200);
23279
+ const row = {
23280
+ id,
23281
+ personId,
23282
+ minutes,
23283
+ on: entry.on ?? todayISO(),
23284
+ ...note ? { note } : {},
23285
+ at: stamp()
23286
+ };
23287
+ set({ tasks: get().tasks.map((t) => t.id === taskId ? {
23288
+ ...t,
23289
+ touchedAt: stamp(),
23290
+ time: [...t.time ?? [], row]
23291
+ } : t) });
23292
+ return id;
23293
+ },
23294
+ removeTime: (taskId, entryId) => {
23295
+ set({ tasks: get().tasks.map((t) => t.id === taskId && t.time?.some((e) => e.id === entryId) ? {
23296
+ ...t,
23297
+ touchedAt: stamp(),
23298
+ time: t.time.filter((e) => e.id !== entryId)
23299
+ } : t) });
23300
+ },
22870
23301
  addAttachment: (a) => {
22871
23302
  const id = a.id ?? uid("f");
22872
23303
  const row = {
@@ -23090,6 +23521,7 @@ var init_tasks = __esmMin((() => {
23090
23521
  init_automations();
23091
23522
  init_focus();
23092
23523
  init_format();
23524
+ init_time_limits();
23093
23525
  init_soundings();
23094
23526
  init_clock();
23095
23527
  init_utils();
@@ -24828,6 +25260,7 @@ function shoreService(raw) {
24828
25260
  if ((host === "discord.com" || host === "discordapp.com" || host === "ptb.discord.com" || host === "canary.discord.com") && /^\/api\/webhooks\/\d+\/[\w-]+$/.test(path)) return "discord";
24829
25261
  if (/^[a-z0-9-]+\.webhook\.office\.com$/.test(host) && path.startsWith("/webhookb2/")) return "teams";
24830
25262
  if ((/^[a-z0-9.-]+\.logic\.azure\.com$/.test(host) || /^[a-z0-9.-]+\.environment\.api\.powerplatform\.com$/.test(host)) && path.includes("/workflows/")) return "teams-workflow";
25263
+ if (host === "chat.googleapis.com" && /^\/v1\/spaces\/[\w-]+\/messages$/.test(path) && url.searchParams.get("key") && url.searchParams.get("token")) return "google-chat";
24831
25264
  return null;
24832
25265
  }
24833
25266
  /** "hooks.slack.com/…8f2Q": enough to recognise an address without showing the secret in it. */
@@ -24846,7 +25279,8 @@ var init_shore = __esmMin((() => {
24846
25279
  slack: "Slack",
24847
25280
  discord: "Discord",
24848
25281
  teams: "Teams",
24849
- "teams-workflow": "Teams"
25282
+ "teams-workflow": "Teams",
25283
+ "google-chat": "Google Chat"
24850
25284
  };
24851
25285
  SHORE_KINDS = [
24852
25286
  {
@@ -24938,7 +25372,7 @@ function memoryHarbor(start, opts) {
24938
25372
  }
24939
25373
  let row = hooks.get(program.id);
24940
25374
  if (ask.url || ask.on?.length || ask.off?.length) {
24941
- if (ask.url && !shoreService(ask.url)) throw new TransportError("That isn’t a Slack, Discord or Teams incoming-webhook address.", 400);
25375
+ if (ask.url && !shoreService(ask.url)) throw new TransportError("That isn’t a Slack, Discord, Teams or Google Chat incoming-webhook address.", 400);
24942
25376
  const url = ask.url || row?.url;
24943
25377
  if (!url) throw new TransportError("Paste the channel’s address first.", 400);
24944
25378
  row = {
@@ -26555,7 +26989,7 @@ function LifeboatsPanel({ id, width, height, go, active, onClose }) {
26555
26989
 
26556
26990
  // src/tui/views/shore.tsx
26557
26991
  /**
26558
- * Ship to shore in the terminal: a program's Slack, Discord or Teams channel
26992
+ * Ship to shore in the terminal: a program's Slack, Discord, Teams or Google Chat channel
26559
26993
  * and which posts go there, as the website's dialog sets it (shore.ts). The
26560
26994
  * address is a secret, so once saved only a hint of it shows. Asks /api/app's
26561
26995
  * `shore` (the in-memory server keeps a pretend one).
@@ -26608,7 +27042,7 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
26608
27042
  if (input === "p") return go.ask("The channel's incoming-webhook address", (text) => {
26609
27043
  const url = text.trim();
26610
27044
  if (!url) return;
26611
- if (!shoreService(url)) return go.notify("That isn't a Slack, Discord or Teams incoming-webhook address. It starts https://hooks.slack.com/, https://discord.com/api/webhooks/ or a Teams workflow address.");
27045
+ if (!shoreService(url)) return go.notify("That isn't a Slack, Discord, Teams or Google Chat incoming-webhook address. It starts https://hooks.slack.com/, https://discord.com/api/webhooks/, https://chat.googleapis.com/ or a Teams workflow address.");
26612
27046
  send({ url }, (a) => `Connected to ${a.hook?.service ?? "the channel"}. Turn on what it posts.`);
26613
27047
  });
26614
27048
  if (!hook) return;
@@ -26654,7 +27088,7 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
26654
27088
  /* @__PURE__ */ jsx(Text, {
26655
27089
  color: theme.muted,
26656
27090
  wrap: "wrap",
26657
- children: "Harbor posts into one Slack, Discord or Teams channel for this program instead of sending mail. Each post links back here."
27091
+ children: "Harbor posts into one Slack, Discord, Teams or Google Chat channel for this program instead of sending mail. Each post links back here."
26658
27092
  }),
26659
27093
  /* @__PURE__ */ jsx(Text, { children: " " }),
26660
27094
  failed ? /* @__PURE__ */ jsx(Text, {