santree 0.7.2 → 0.7.3

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.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { formatDueDate } from "./due.js";
3
+ import { formatSla, isSnoozed } from "./sla.js";
4
4
  import { issueReadiness } from "../trackers/types.js";
5
5
  function stateColor(type) {
6
6
  switch (type) {
@@ -210,30 +210,38 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
210
210
  // ── Hero: identifier + title, then a status pill row ───────────────
211
211
  lines.push({ text: `${li.identifier} ${li.title}`, bold: true });
212
212
  const sc = stateColor(li.state.type);
213
- const heroSegs = [
214
- { text: "● ", color: sc },
215
- { text: li.state.name, color: sc },
216
- { text: " · ", dim: true },
217
- { text: li.priorityLabel },
218
- ];
213
+ const heroSegs = [];
214
+ // On the Triage tab every issue is in the "Triage" state by definition, so the
215
+ // status pill is noise — lead with priority instead.
216
+ if (!triage) {
217
+ heroSegs.push({ text: "● ", color: sc }, { text: li.state.name, color: sc }, { text: " · ", dim: true });
218
+ }
219
+ heroSegs.push({ text: li.priorityLabel });
219
220
  if (li.labels.length > 0) {
220
221
  heroSegs.push({ text: " · ", dim: true });
221
222
  heroSegs.push({ text: li.labels.join(", "), dim: true });
222
223
  }
223
224
  lines.push({ text: "", segments: heroSegs });
224
- // ── Due date ──────────────────────────────────────────────────────
225
- // Urgency-coded; shown whenever the issue carries one (most relevant on the
226
- // Triage tab, harmless elsewhere).
227
- const due = formatDueDate(li.dueDate);
228
- if (due) {
225
+ // ── SLA countdown ─────────────────────────────────────────────────
226
+ // Urgency-coded time-to-breach; shown whenever the issue carries an SLA
227
+ // (Triage tab). Snoozed issues are parked, so render greyed with a marker.
228
+ const sla = formatSla(li.slaBreachesAt);
229
+ const snoozed = isSnoozed(li.snoozedUntilAt);
230
+ if (sla) {
231
+ const col = snoozed ? "gray" : sla.color;
232
+ const bold = !snoozed && sla.urgent;
229
233
  lines.push({
230
234
  text: "",
231
235
  segments: [
232
- { text: "◷ ", color: due.color, bold: due.urgent },
233
- { text: due.label, color: due.color, bold: due.urgent },
236
+ { text: "◷ ", color: col, bold },
237
+ { text: sla.label, color: col, bold },
238
+ ...(snoozed ? [{ text: " · snoozed", color: "gray" }] : []),
234
239
  ],
235
240
  });
236
241
  }
242
+ else if (snoozed) {
243
+ lines.push({ text: "", segments: [{ text: "◷ snoozed", color: "gray" }] });
244
+ }
237
245
  // ── Description ───────────────────────────────────────────────────
238
246
  if (li.description) {
239
247
  lines.push({ text: "" });
@@ -10,7 +10,7 @@ interface Props {
10
10
  selectionBg?: string;
11
11
  /** Right-column variant (row structure — and click→row mapping — is identical):
12
12
  * "default" — WT + CI status columns (Trees tab)
13
- * "triage" — a colored due-date badge
13
+ * "triage" — a colored SLA-countdown badge
14
14
  * "issues" — a readiness glyph (ready / blocked by dependencies) */
15
15
  variant?: "default" | "triage" | "issues";
16
16
  /** Ticket ids whose worktree is currently being removed — shown with a
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { formatDueDate } from "./due.js";
3
+ import { formatSla, isSnoozed } from "./sla.js";
4
4
  import { issueReadiness } from "../trackers/types.js";
5
5
  function stateColor(type, name) {
6
6
  const n = name?.toLowerCase();
@@ -73,7 +73,13 @@ export function buildIssueListRows(groups, flatIssues) {
73
73
  rows.push({ kind: "spacer" });
74
74
  rows.push({ kind: "header", name: group.name, count: totalIssues, isFirst: gi === 0 });
75
75
  for (const sg of group.statusGroups) {
76
- rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
76
+ // A blank status name suppresses the per-status sub-header (used by the
77
+ // Triage tab, where the single group needs no "Triage" header). Both the
78
+ // renderer and the click→row mapper call this builder, so skipping a row
79
+ // here keeps their indices aligned automatically.
80
+ if (sg.name) {
81
+ rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
82
+ }
77
83
  for (const di of sg.issues) {
78
84
  pushIssueWithChildren(di, 0);
79
85
  }
@@ -87,9 +93,9 @@ export function buildIssueListRows(groups, flatIssues) {
87
93
  // Glyphs are 1 char and rendered right-aligned within their column.
88
94
  const LEFT_FIXED = 1 + 1 + 1 + 2 + 11; // 16 — left-aligned columns
89
95
  const RIGHT_FIXED = 2 + 2 + 2; // 6 — WT + 2 spaces + CI
90
- // Triage variant: a single right-aligned due-date column. Widest badge is
91
- // "⚠ overdue 99d" (13 chars); pad/clamp everything to this so titles align.
92
- const DUE_COL_WIDTH = 13;
96
+ // Triage variant: a single right-aligned SLA-countdown column. Widest badge is
97
+ // "breached" (8 chars); pad/clamp everything to this so titles align.
98
+ const SLA_COL_WIDTH = 8;
93
99
  // Issues variant: a single readiness glyph under a "RDY" header.
94
100
  const READY_COL_WIDTH = 3;
95
101
  const TITLE_GAP = 2; // minimum spacing between title and the right columns
@@ -106,7 +112,7 @@ function readinessGlyph(di) {
106
112
  export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", variant = "default", deletingIds, }) {
107
113
  const isTriage = variant === "triage";
108
114
  const isIssues = variant === "issues";
109
- const rightFixed = isTriage ? DUE_COL_WIDTH : isIssues ? READY_COL_WIDTH : RIGHT_FIXED;
115
+ const rightFixed = isTriage ? SLA_COL_WIDTH : isIssues ? READY_COL_WIDTH : RIGHT_FIXED;
110
116
  const rows = buildIssueListRows(groups, flatIssues);
111
117
  const visible = rows.slice(scrollOffset, scrollOffset + height);
112
118
  const titleMaxWidth = Math.max(width - LEFT_FIXED - 1 /* leading space */ - rightFixed - TITLE_GAP, 10);
@@ -121,7 +127,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
121
127
  // Label "WT CI" is 5 chars; the "W" lines up with the WT
122
128
  // glyph at column (width - RIGHT_FIXED + 1).
123
129
  const namePart = `${row.name} ${row.count}`;
124
- const labelText = isTriage ? "DUE" : isIssues ? "RDY" : "WT CI";
130
+ const labelText = isTriage ? "SLA" : isIssues ? "RDY" : "WT CI";
125
131
  const labelPad = row.isFirst
126
132
  ? Math.max(2, width - namePart.length - labelText.length)
127
133
  : 0;
@@ -133,8 +139,11 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
133
139
  const { issue, flatIndex, depth } = row;
134
140
  const selected = flatIndex === selectedIndex;
135
141
  const di = issue;
136
- const sc = stateColor(di.issue.state.type, di.issue.state.name);
137
- const prio = priorityMarker(di.issue.priority);
142
+ // Snoozed triage rows are parked work: grey them out (dot + id +
143
+ // title + SLA) so the active items the user should pick up stand out.
144
+ const snoozed = isTriage && isSnoozed(di.issue.snoozedUntilAt);
145
+ const sc = snoozed ? "gray" : stateColor(di.issue.state.type, di.issue.state.name);
146
+ const prio = snoozed ? { glyph: " ", color: "gray" } : priorityMarker(di.issue.priority);
138
147
  const isDeleting = deletingIds?.has(di.issue.identifier) ?? false;
139
148
  const work = isDeleting ? { glyph: "⌫", color: "yellow" } : workIndicator(di.worktree);
140
149
  const ci = ciIndicator(di.checks);
@@ -147,12 +156,13 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
147
156
  // Pad between title and the right columns so the markers stay
148
157
  // pinned to the right edge regardless of title length.
149
158
  const trailingPad = Math.max(0, width - LEFT_FIXED - 1 - nestPrefix.length - title.length - rightFixed);
150
- // Triage variant: a single right-aligned due-date badge in place
151
- // of the WT/CI columns.
152
- const due = isTriage ? formatDueDate(di.issue.dueDate) : null;
153
- const dueText = (due?.label ?? "").padStart(DUE_COL_WIDTH).slice(-DUE_COL_WIDTH);
159
+ // Triage variant: a single right-aligned SLA-countdown badge in
160
+ // place of the WT/CI columns. Snoozed rows show it greyed.
161
+ const sla = isTriage ? formatSla(di.issue.slaBreachesAt) : null;
162
+ const slaColor = snoozed ? "gray" : sla?.color;
163
+ const slaText = (sla?.label ?? "").padStart(SLA_COL_WIDTH).slice(-SLA_COL_WIDTH);
154
164
  // Issues variant: a single readiness glyph right-aligned under "RDY".
155
165
  const ready = isIssues ? readinessGlyph(di) : null;
156
- return (_jsxs(Box, { width: width, children: [_jsx(Text, { backgroundColor: bg, color: prio.color, children: prio.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, dimColor: true, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsxs(Text, { backgroundColor: bg, bold: selected, children: [" ", title] }), _jsx(Text, { backgroundColor: bg, children: " ".repeat(trailingPad) }), isTriage ? (_jsx(Text, { backgroundColor: bg, color: due?.color, bold: due?.urgent, children: dueText })) : isIssues ? (_jsx(Text, { backgroundColor: bg, color: ready?.color, children: ` ${ready?.glyph ?? " "}` })) : (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: work.color, children: work.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: ci.color, children: ci.glyph })] }))] }, di.issue.identifier));
166
+ return (_jsxs(Box, { width: width, children: [_jsx(Text, { backgroundColor: bg, color: prio.color, children: prio.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, dimColor: true, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsxs(Text, { backgroundColor: bg, bold: selected, color: snoozed ? "gray" : undefined, children: [" ", title] }), _jsx(Text, { backgroundColor: bg, children: " ".repeat(trailingPad) }), isTriage ? (_jsx(Text, { backgroundColor: bg, color: slaColor, bold: !snoozed && sla?.urgent, children: slaText })) : isIssues ? (_jsx(Text, { backgroundColor: bg, color: ready?.color, children: ` ${ready?.glyph ?? " "}` })) : (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: work.color, children: work.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: ci.color, children: ci.glyph })] }))] }, di.issue.identifier));
157
167
  }) }) }));
158
168
  }
@@ -34,7 +34,7 @@ const LEGEND = [
34
34
  {
35
35
  glyph: "Triage",
36
36
  color: "cyan",
37
- meaning: "Incoming inbox — due dates + comments (Linear only)",
37
+ meaning: "Incoming inbox — SLA countdown + comments (Linear only)",
38
38
  },
39
39
  { glyph: "Issues", color: "cyan", meaning: "Backlog / planning" },
40
40
  { glyph: "Trees", color: "cyan", meaning: "Worktrees in progress" },
@@ -63,8 +63,9 @@ const LEGEND = [
63
63
  { glyph: "✗", color: "red", meaning: "CI column: a check is failing" },
64
64
  { glyph: "●", color: "yellow", meaning: "CI column: checks pending / running" },
65
65
  { glyph: "·", color: "gray", meaning: "CI column: no PR or no checks" },
66
- { glyph: "◷", color: "red", meaning: "DUE column (Triage): overdue / due today" },
67
- { glyph: "◷", color: "yellow", meaning: "DUE column (Triage): due within 2 days" },
66
+ { glyph: "◷", color: "red", meaning: "SLA column (Triage): breached / under a day" },
67
+ { glyph: "◷", color: "yellow", meaning: "SLA column (Triage): under two days" },
68
+ { glyph: "●", color: "gray", meaning: "Triage row greyed: snoozed (parked, sunk to bottom)" },
68
69
  { glyph: "✓", color: "green", meaning: "RDY column (Issues): ready — no open blockers" },
69
70
  {
70
71
  glyph: "⊘",
@@ -3,6 +3,7 @@ import { runAsync } from "../exec.js";
3
3
  import { readMainAgentTodos, findClaudeSessionCwd } from "../claude-todos.js";
4
4
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, getGitHubUserNameAsync, } from "../github.js";
5
5
  import { getIssueTracker, getCandidateTrackers } from "../trackers/index.js";
6
+ import { isSnoozed } from "./sla.js";
6
7
  export async function loadDashboardData(repoRoot) {
7
8
  // Fetch issues and worktrees in parallel
8
9
  const tracker = getIssueTracker(repoRoot);
@@ -266,24 +267,36 @@ export async function loadDashboardData(repoRoot) {
266
267
  const triageIssues = enriched.filter(isTriage);
267
268
  const backlogIssues = enriched.filter((di) => !di.worktree && !isTriage(di));
268
269
  const treeIssues = enriched.filter((di) => di.worktree);
269
- // Surface the most pressing triage items first: by due date ascending
270
- // (overdue/soonest first), undated last. `dueDate` is `YYYY-MM-DD` so a
271
- // plain string compare is chronological. buildProjectGroups preserves this
272
- // order within each status group.
270
+ // Order the triage inbox so the work that needs attention now is on top:
271
+ // active (non-snoozed) items first, snoozed parked at the bottom; within
272
+ // each, by SLA breach time ascending (breached/soonest first), SLA-less last.
273
+ const slaRank = (di) => {
274
+ const s = di.issue.slaBreachesAt;
275
+ const t = s ? Date.parse(s) : NaN;
276
+ return Number.isNaN(t) ? Number.POSITIVE_INFINITY : t;
277
+ };
273
278
  triageIssues.sort((a, b) => {
274
- const da = a.issue.dueDate ?? null;
275
- const db = b.issue.dueDate ?? null;
276
- if (da && db)
277
- return da < db ? -1 : da > db ? 1 : 0;
278
- if (da)
279
- return -1;
280
- if (db)
281
- return 1;
282
- return 0;
279
+ const sa = isSnoozed(a.issue.snoozedUntilAt) ? 1 : 0;
280
+ const sb = isSnoozed(b.issue.snoozedUntilAt) ? 1 : 0;
281
+ if (sa !== sb)
282
+ return sa - sb;
283
+ return slaRank(a) - slaRank(b);
283
284
  });
284
285
  const groups = buildProjectGroups(backlogIssues);
285
286
  const flatIssues = flatten(groups);
286
- const triageGroups = buildProjectGroups(triageIssues);
287
+ // Triage is scoped to issues assigned to the viewer, so project grouping and
288
+ // the redundant "Triage" status header add noise. Render one flat group under
289
+ // a single "Assigned to me" header (its column label is the SLA badge). The
290
+ // empty status-group name makes IssueList skip the per-status sub-header.
291
+ const triageGroups = triageIssues.length
292
+ ? [
293
+ {
294
+ name: "Assigned to me",
295
+ id: null,
296
+ statusGroups: [{ name: "", type: "triage", issues: triageIssues }],
297
+ },
298
+ ]
299
+ : [];
287
300
  const flatTriage = flatten(triageGroups);
288
301
  const treeGroups = buildProjectGroups(treeIssues);
289
302
  const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Triage SLA + snooze formatting for the Triage tab. Linear issues in triage
3
+ * carry an `slaBreachesAt` ISO timestamp (when the response SLA is breached);
4
+ * we render a short, urgency-coded countdown badge — the same "2d 23h" / "13h"
5
+ * shape Linear itself shows — used by both the issue list (right column) and
6
+ * the detail panel.
7
+ *
8
+ * Urgency buckets (by time until breach):
9
+ * - breached (≤ 0) → red, "breached"
10
+ * - imminent (< 24h) → red, "5h" / "45m"
11
+ * - soon (< 48h) → yellow, "1d 6h"
12
+ * - later (≥ 48h) → gray, "3d 2h"
13
+ *
14
+ * Returns null when there is no SLA so callers can render nothing.
15
+ */
16
+ export interface SlaInfo {
17
+ /** Short badge text, e.g. "2d 23h", "13h", or "breached". */
18
+ label: string;
19
+ /** Ink color name keyed to urgency. */
20
+ color: "red" | "yellow" | "gray";
21
+ /** True for breached / < 24h — the cases worth a bold warning. */
22
+ urgent: boolean;
23
+ }
24
+ export declare function formatSla(slaBreachesAt: string | null | undefined, now?: Date): SlaInfo | null;
25
+ /** True when the issue is snoozed past the current moment. A snoozed triage
26
+ * issue is parked — the UI greys it and sinks it below active work. */
27
+ export declare function isSnoozed(snoozedUntilAt: string | null | undefined, now?: Date): boolean;
@@ -0,0 +1,35 @@
1
+ export function formatSla(slaBreachesAt, now = new Date()) {
2
+ if (!slaBreachesAt)
3
+ return null;
4
+ const breach = Date.parse(slaBreachesAt);
5
+ if (Number.isNaN(breach))
6
+ return null;
7
+ const ms = breach - now.getTime();
8
+ if (ms <= 0)
9
+ return { label: "breached", color: "red", urgent: true };
10
+ const totalMin = Math.floor(ms / 60_000);
11
+ const days = Math.floor(totalMin / 1440);
12
+ const hours = Math.floor((totalMin % 1440) / 60);
13
+ const mins = totalMin % 60;
14
+ let label;
15
+ if (days >= 1)
16
+ label = `${days}d ${hours}h`;
17
+ else if (hours >= 1)
18
+ label = `${hours}h`;
19
+ else
20
+ label = `${mins}m`;
21
+ const hoursLeft = ms / 3_600_000;
22
+ if (hoursLeft < 24)
23
+ return { label, color: "red", urgent: true };
24
+ if (hoursLeft < 48)
25
+ return { label, color: "yellow", urgent: false };
26
+ return { label, color: "gray", urgent: false };
27
+ }
28
+ /** True when the issue is snoozed past the current moment. A snoozed triage
29
+ * issue is parked — the UI greys it and sinks it below active work. */
30
+ export function isSnoozed(snoozedUntilAt, now = new Date()) {
31
+ if (!snoozedUntilAt)
32
+ return false;
33
+ const until = Date.parse(snoozedUntilAt);
34
+ return !Number.isNaN(until) && until > now.getTime();
35
+ }
@@ -37,7 +37,8 @@ query GetIssue($id: String!) {
37
37
  title
38
38
  description
39
39
  url
40
- dueDate
40
+ slaBreachesAt
41
+ snoozedUntilAt
41
42
  state { name type }
42
43
  priority
43
44
  labels { nodes { name } }
@@ -73,7 +74,8 @@ query AssignedIssues {
73
74
  title
74
75
  description
75
76
  url
76
- dueDate
77
+ slaBreachesAt
78
+ snoozedUntilAt
77
79
  priority
78
80
  state { name type }
79
81
  labels { nodes { name } }
@@ -97,7 +99,8 @@ function mapAssigned(issue) {
97
99
  title: issue.title,
98
100
  description: issue.description ?? null,
99
101
  url: issue.url,
100
- dueDate: issue.dueDate ?? null,
102
+ slaBreachesAt: issue.slaBreachesAt ?? null,
103
+ snoozedUntilAt: issue.snoozedUntilAt ?? null,
101
104
  priority: issue.priority,
102
105
  priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
103
106
  state: {
@@ -38,11 +38,16 @@ export interface AssignedIssue {
38
38
  blockedBy?: IssueRef[];
39
39
  /** Issues this one blocks (downstream dependents). */
40
40
  blocking?: IssueRef[];
41
- /** Due date as an ISO `YYYY-MM-DD` string, or null when none is set.
42
- * Only trackers with a native due-date concept populate it (Linear today);
43
- * others leave it undefined. Surfaced on the Triage tab as a colored,
44
- * urgency-coded badge. */
45
- dueDate?: string | null;
41
+ /** When the issue's triage SLA breaches, as an ISO timestamp, or null when
42
+ * no SLA applies. Only trackers with a native SLA concept populate it
43
+ * (Linear today); others leave it undefined. Surfaced on the Triage tab as a
44
+ * colored, urgency-coded countdown badge. */
45
+ slaBreachesAt?: string | null;
46
+ /** When the issue is snoozed until, as an ISO timestamp, or null when not
47
+ * snoozed. A snooze in the future means the issue is parked — surfaced on the
48
+ * Triage tab as a greyed, sunk-to-the-bottom row so active work stands out.
49
+ * Linear-only today. */
50
+ snoozedUntilAt?: string | null;
46
51
  }
47
52
  export interface Issue extends AssignedIssue {
48
53
  comments: Comment[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -1,24 +0,0 @@
1
- /**
2
- * Due-date formatting for the Triage tab. Turns Linear's `YYYY-MM-DD`
3
- * `dueDate` string into a short, urgency-coded badge used by both the issue
4
- * list (right column) and the detail panel.
5
- *
6
- * Urgency buckets (by whole calendar days from today, local time):
7
- * - overdue (< 0) → red, "⚠ overdue Nd"
8
- * - due today (0) → red, "⚠ due today"
9
- * - soon (1–2 days) → yellow, "due in Nd"
10
- * - later (≥ 3 days) → gray, "due Mon D"
11
- *
12
- * Returns null when there is no due date so callers can render nothing.
13
- */
14
- export interface DueInfo {
15
- /** Short badge text, e.g. "⚠ overdue 3d" or "due Jun 12". */
16
- label: string;
17
- /** Ink color name keyed to urgency. */
18
- color: "red" | "yellow" | "gray";
19
- /** True for overdue/today — the cases worth a warning glyph. */
20
- urgent: boolean;
21
- /** Whole days until due (negative when overdue). */
22
- days: number;
23
- }
24
- export declare function formatDueDate(dueDate: string | null | undefined, now?: Date): DueInfo | null;
@@ -1,32 +0,0 @@
1
- /** Whole-day difference between two dates, ignoring time-of-day. */
2
- function dayDiff(from, to) {
3
- const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
4
- const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
5
- return Math.round((b - a) / 86_400_000);
6
- }
7
- export function formatDueDate(dueDate, now = new Date()) {
8
- if (!dueDate)
9
- return null;
10
- // Parse `YYYY-MM-DD` as a local date (not UTC) so "today" matches the user's
11
- // wall clock. `new Date("2026-06-12")` would parse as UTC midnight; build the
12
- // date from parts instead.
13
- const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dueDate);
14
- if (!m)
15
- return null;
16
- const due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
17
- if (Number.isNaN(due.getTime()))
18
- return null;
19
- const days = dayDiff(now, due);
20
- const monthDay = due.toLocaleDateString("en-US", { month: "short", day: "numeric" });
21
- if (days < 0) {
22
- const n = Math.abs(days);
23
- return { label: `⚠ overdue ${n}d`, color: "red", urgent: true, days };
24
- }
25
- if (days === 0) {
26
- return { label: "⚠ due today", color: "red", urgent: true, days };
27
- }
28
- if (days <= 2) {
29
- return { label: `due in ${days}d`, color: "yellow", urgent: false, days };
30
- }
31
- return { label: `due ${monthDay}`, color: "gray", urgent: false, days };
32
- }