@task-mcp/shared 1.0.13 → 1.0.14

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.
Files changed (104) hide show
  1. package/dist/algorithms/critical-path.d.ts.map +1 -1
  2. package/dist/algorithms/critical-path.js +2 -14
  3. package/dist/algorithms/critical-path.js.map +1 -1
  4. package/dist/algorithms/dependency-integrity.d.ts +8 -0
  5. package/dist/algorithms/dependency-integrity.d.ts.map +1 -1
  6. package/dist/algorithms/dependency-integrity.js +42 -24
  7. package/dist/algorithms/dependency-integrity.js.map +1 -1
  8. package/dist/algorithms/dependency-integrity.test.d.ts +2 -0
  9. package/dist/algorithms/dependency-integrity.test.d.ts.map +1 -0
  10. package/dist/algorithms/dependency-integrity.test.js +309 -0
  11. package/dist/algorithms/dependency-integrity.test.js.map +1 -0
  12. package/dist/algorithms/tech-analysis.d.ts +5 -5
  13. package/dist/algorithms/tech-analysis.d.ts.map +1 -1
  14. package/dist/algorithms/tech-analysis.js +65 -17
  15. package/dist/algorithms/tech-analysis.js.map +1 -1
  16. package/dist/algorithms/topological-sort.d.ts.map +1 -1
  17. package/dist/algorithms/topological-sort.js +1 -56
  18. package/dist/algorithms/topological-sort.js.map +1 -1
  19. package/dist/schemas/index.d.ts +1 -0
  20. package/dist/schemas/index.d.ts.map +1 -1
  21. package/dist/schemas/index.js +2 -0
  22. package/dist/schemas/index.js.map +1 -1
  23. package/dist/schemas/project.d.ts +6 -6
  24. package/dist/schemas/state.d.ts +17 -0
  25. package/dist/schemas/state.d.ts.map +1 -0
  26. package/dist/schemas/state.js +17 -0
  27. package/dist/schemas/state.js.map +1 -0
  28. package/dist/schemas/task.d.ts +13 -4
  29. package/dist/schemas/task.d.ts.map +1 -1
  30. package/dist/schemas/task.js +3 -0
  31. package/dist/schemas/task.js.map +1 -1
  32. package/dist/schemas/view.d.ts +4 -4
  33. package/dist/utils/dashboard-renderer.d.ts +3 -0
  34. package/dist/utils/dashboard-renderer.d.ts.map +1 -1
  35. package/dist/utils/dashboard-renderer.js +12 -13
  36. package/dist/utils/dashboard-renderer.js.map +1 -1
  37. package/dist/utils/dashboard-renderer.test.d.ts +2 -0
  38. package/dist/utils/dashboard-renderer.test.d.ts.map +1 -0
  39. package/dist/utils/dashboard-renderer.test.js +777 -0
  40. package/dist/utils/dashboard-renderer.test.js.map +1 -0
  41. package/dist/utils/date.d.ts +49 -0
  42. package/dist/utils/date.d.ts.map +1 -1
  43. package/dist/utils/date.js +174 -19
  44. package/dist/utils/date.js.map +1 -1
  45. package/dist/utils/date.test.js +139 -1
  46. package/dist/utils/date.test.js.map +1 -1
  47. package/dist/utils/hierarchy.d.ts +1 -1
  48. package/dist/utils/hierarchy.d.ts.map +1 -1
  49. package/dist/utils/hierarchy.js +15 -5
  50. package/dist/utils/hierarchy.js.map +1 -1
  51. package/dist/utils/hierarchy.test.d.ts +2 -0
  52. package/dist/utils/hierarchy.test.d.ts.map +1 -0
  53. package/dist/utils/hierarchy.test.js +351 -0
  54. package/dist/utils/hierarchy.test.js.map +1 -0
  55. package/dist/utils/id.js +1 -1
  56. package/dist/utils/id.js.map +1 -1
  57. package/dist/utils/index.d.ts +3 -2
  58. package/dist/utils/index.d.ts.map +1 -1
  59. package/dist/utils/index.js +3 -2
  60. package/dist/utils/index.js.map +1 -1
  61. package/dist/utils/natural-language.d.ts.map +1 -1
  62. package/dist/utils/natural-language.js +7 -0
  63. package/dist/utils/natural-language.js.map +1 -1
  64. package/dist/utils/natural-language.test.js +24 -0
  65. package/dist/utils/natural-language.test.js.map +1 -1
  66. package/dist/utils/priority-queue.d.ts +17 -0
  67. package/dist/utils/priority-queue.d.ts.map +1 -0
  68. package/dist/utils/priority-queue.js +62 -0
  69. package/dist/utils/priority-queue.js.map +1 -0
  70. package/dist/utils/projection.d.ts +9 -0
  71. package/dist/utils/projection.d.ts.map +1 -1
  72. package/dist/utils/projection.js +37 -0
  73. package/dist/utils/projection.js.map +1 -1
  74. package/dist/utils/terminal-ui.d.ts +5 -0
  75. package/dist/utils/terminal-ui.d.ts.map +1 -1
  76. package/dist/utils/terminal-ui.js +88 -11
  77. package/dist/utils/terminal-ui.js.map +1 -1
  78. package/dist/utils/terminal-ui.test.d.ts +2 -0
  79. package/dist/utils/terminal-ui.test.d.ts.map +1 -0
  80. package/dist/utils/terminal-ui.test.js +683 -0
  81. package/dist/utils/terminal-ui.test.js.map +1 -0
  82. package/package.json +1 -1
  83. package/src/algorithms/critical-path.ts +6 -14
  84. package/src/algorithms/dependency-integrity.test.ts +348 -0
  85. package/src/algorithms/dependency-integrity.ts +41 -26
  86. package/src/algorithms/tech-analysis.ts +86 -18
  87. package/src/algorithms/topological-sort.ts +1 -62
  88. package/src/schemas/index.ts +3 -0
  89. package/src/schemas/state.ts +23 -0
  90. package/src/schemas/task.ts +3 -0
  91. package/src/utils/dashboard-renderer.test.ts +981 -0
  92. package/src/utils/dashboard-renderer.ts +14 -15
  93. package/src/utils/date.test.ts +170 -1
  94. package/src/utils/date.ts +214 -19
  95. package/src/utils/hierarchy.test.ts +411 -0
  96. package/src/utils/hierarchy.ts +22 -5
  97. package/src/utils/id.ts +1 -1
  98. package/src/utils/index.ts +17 -1
  99. package/src/utils/natural-language.test.ts +28 -0
  100. package/src/utils/natural-language.ts +8 -0
  101. package/src/utils/priority-queue.ts +68 -0
  102. package/src/utils/projection.ts +46 -2
  103. package/src/utils/terminal-ui.test.ts +831 -0
  104. package/src/utils/terminal-ui.ts +90 -10
@@ -15,6 +15,7 @@ import {
15
15
  pad,
16
16
  banner,
17
17
  stripAnsi,
18
+ formatPriority,
18
19
  type TableColumn,
19
20
  } from "./terminal-ui.js";
20
21
 
@@ -53,6 +54,7 @@ export interface DashboardData {
53
54
  inboxItems?: InboxItem[] | undefined;
54
55
  currentProject?: Project | undefined;
55
56
  version?: string | undefined;
57
+ activeTag?: string | undefined;
56
58
  }
57
59
 
58
60
  // =============================================================================
@@ -158,16 +160,6 @@ export function calculateDependencyMetrics(tasks: Task[]): DependencyMetrics {
158
160
  // Formatters
159
161
  // =============================================================================
160
162
 
161
- function formatPriority(priority: Task["priority"]): string {
162
- const colors: Record<string, (s: string) => string> = {
163
- critical: c.red,
164
- high: c.yellow,
165
- medium: c.blue,
166
- low: c.gray,
167
- };
168
- return (colors[priority] ?? c.gray)(priority);
169
- }
170
-
171
163
  function getTimeAgo(date: Date): string {
172
164
  const now = new Date();
173
165
  const diffMs = now.getTime() - date.getTime();
@@ -488,7 +480,7 @@ export function renderDashboard(
488
480
  stripAnsiCodes = false,
489
481
  } = options;
490
482
 
491
- const { tasks, projects, inboxItems = [], currentProject, version } = data;
483
+ const { tasks, projects, inboxItems = [], currentProject, version, activeTag } = data;
492
484
  const lines: string[] = [];
493
485
 
494
486
  // Banner
@@ -503,8 +495,13 @@ export function renderDashboard(
503
495
  ? `${c.bold("Project:")} ${currentProject.name}`
504
496
  : `${c.bold("All Projects")} (${projects.length} projects)`;
505
497
 
506
- if (version) {
507
- lines.push(c.dim(`v${version} ${projectInfo}`));
498
+ // Build header line with version and tag
499
+ const headerParts: string[] = [];
500
+ if (version) headerParts.push(`v${version}`);
501
+ if (activeTag) headerParts.push(`#${activeTag}`);
502
+
503
+ if (headerParts.length > 0) {
504
+ lines.push(c.dim(`${headerParts.join(" ")} ${projectInfo}`));
508
505
  } else {
509
506
  lines.push(projectInfo);
510
507
  }
@@ -557,13 +554,14 @@ export function renderDashboard(
557
554
  export function renderProjectDashboard(
558
555
  project: Project,
559
556
  tasks: Task[],
560
- options: { stripAnsiCodes?: boolean; version?: string } = {}
557
+ options: { stripAnsiCodes?: boolean; version?: string; activeTag?: string } = {}
561
558
  ): string {
562
559
  const data: DashboardData = {
563
560
  tasks,
564
561
  projects: [project],
565
562
  currentProject: project,
566
563
  version: options.version,
564
+ activeTag: options.activeTag,
567
565
  };
568
566
 
569
567
  return renderDashboard(
@@ -587,13 +585,14 @@ export function renderGlobalDashboard(
587
585
  allTasks: Task[],
588
586
  inboxItems: InboxItem[],
589
587
  getProjectTasks: (projectId: string) => Task[],
590
- options: { stripAnsiCodes?: boolean; version?: string } = {}
588
+ options: { stripAnsiCodes?: boolean; version?: string; activeTag?: string } = {}
591
589
  ): string {
592
590
  const data: DashboardData = {
593
591
  tasks: allTasks,
594
592
  projects,
595
593
  inboxItems,
596
594
  version: options.version,
595
+ activeTag: options.activeTag,
597
596
  };
598
597
 
599
598
  return renderDashboard(data, getProjectTasks, {
@@ -1,5 +1,17 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { now, parseRelativeDate, formatDate, isToday, isPastDue, isWithinDays } from "./date.js";
2
+ import {
3
+ now,
4
+ parseRelativeDate,
5
+ parseRelativeDateSafe,
6
+ parseDateString,
7
+ formatDate,
8
+ formatDisplayDate,
9
+ isToday,
10
+ isPastDue,
11
+ isWithinDays,
12
+ isValidDate,
13
+ DateParseError,
14
+ } from "./date.js";
3
15
 
4
16
  describe("now", () => {
5
17
  test("returns ISO timestamp string", () => {
@@ -102,6 +114,137 @@ describe("parseRelativeDate", () => {
102
114
  });
103
115
  });
104
116
 
117
+ describe("parseRelativeDateSafe", () => {
118
+ test("returns success result for valid date", () => {
119
+ const result = parseRelativeDateSafe("tomorrow");
120
+ expect(result.success).toBe(true);
121
+ if (result.success === true) {
122
+ const tomorrow = new Date();
123
+ tomorrow.setDate(tomorrow.getDate() + 1);
124
+ expect(result.date.getDate()).toBe(tomorrow.getDate());
125
+ }
126
+ });
127
+
128
+ test("returns error for empty input", () => {
129
+ const result = parseRelativeDateSafe("");
130
+ expect(result.success).toBe(false);
131
+ if (result.success === false) {
132
+ expect(result.reason).toBe("empty_input");
133
+ expect(result.error).toContain("empty");
134
+ }
135
+ });
136
+
137
+ test("returns error for whitespace-only input", () => {
138
+ const result = parseRelativeDateSafe(" ");
139
+ expect(result.success).toBe(false);
140
+ if (result.success === false) {
141
+ expect(result.reason).toBe("empty_input");
142
+ }
143
+ });
144
+
145
+ test("returns error for invalid date format", () => {
146
+ const result = parseRelativeDateSafe("not a date");
147
+ expect(result.success).toBe(false);
148
+ if (result.success === false) {
149
+ expect(result.reason).toBe("invalid_format");
150
+ expect(result.error).toContain("Supported formats");
151
+ }
152
+ });
153
+
154
+ test("returns error for malformed ISO date", () => {
155
+ const result = parseRelativeDateSafe("2025-1-5");
156
+ expect(result.success).toBe(false);
157
+ if (result.success === false) {
158
+ expect(result.reason).toBe("invalid_format");
159
+ expect(result.error).toContain("zero-padded");
160
+ }
161
+ });
162
+
163
+ test("returns error for invalid date like Feb 30", () => {
164
+ const result = parseRelativeDateSafe("2025-02-30");
165
+ expect(result.success).toBe(false);
166
+ if (result.success === false) {
167
+ expect(result.reason).toBe("invalid_date");
168
+ expect(result.error).toContain("February 30");
169
+ }
170
+ });
171
+
172
+ test("returns error for year out of range", () => {
173
+ const result = parseRelativeDateSafe("1800-01-01");
174
+ expect(result.success).toBe(false);
175
+ if (result.success === false) {
176
+ expect(result.reason).toBe("out_of_range");
177
+ expect(result.error).toContain("1900-2100");
178
+ }
179
+ });
180
+
181
+ test("returns error for excessive day offset", () => {
182
+ const result = parseRelativeDateSafe("in 5000 days");
183
+ expect(result.success).toBe(false);
184
+ if (result.success === false) {
185
+ expect(result.reason).toBe("out_of_range");
186
+ expect(result.error).toContain("3650");
187
+ }
188
+ });
189
+ });
190
+
191
+ describe("isValidDate", () => {
192
+ test("returns true for valid Date", () => {
193
+ expect(isValidDate(new Date())).toBe(true);
194
+ expect(isValidDate(new Date("2025-01-01"))).toBe(true);
195
+ });
196
+
197
+ test("returns false for Invalid Date", () => {
198
+ expect(isValidDate(new Date("invalid"))).toBe(false);
199
+ expect(isValidDate(new Date(NaN))).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe("parseDateString", () => {
204
+ test("parses valid date string", () => {
205
+ const result = parseDateString("2025-01-15");
206
+ expect(result).toBeInstanceOf(Date);
207
+ expect(result.getFullYear()).toBe(2025);
208
+ });
209
+
210
+ test("throws DateParseError for empty string", () => {
211
+ expect(() => parseDateString("")).toThrow(DateParseError);
212
+ try {
213
+ parseDateString("");
214
+ } catch (e) {
215
+ expect(e).toBeInstanceOf(DateParseError);
216
+ expect((e as DateParseError).reason).toBe("empty_input");
217
+ }
218
+ });
219
+
220
+ test("throws DateParseError for invalid string", () => {
221
+ expect(() => parseDateString("not a date")).toThrow(DateParseError);
222
+ try {
223
+ parseDateString("not a date");
224
+ } catch (e) {
225
+ expect(e).toBeInstanceOf(DateParseError);
226
+ expect((e as DateParseError).reason).toBe("invalid_format");
227
+ expect((e as DateParseError).input).toBe("not a date");
228
+ }
229
+ });
230
+ });
231
+
232
+ describe("formatDisplayDate error handling", () => {
233
+ test("throws DateParseError for invalid string", () => {
234
+ expect(() => formatDisplayDate("invalid")).toThrow(DateParseError);
235
+ });
236
+
237
+ test("throws DateParseError for Invalid Date object", () => {
238
+ expect(() => formatDisplayDate(new Date("invalid"))).toThrow(DateParseError);
239
+ });
240
+
241
+ test("formats valid string date", () => {
242
+ const result = formatDisplayDate("2025-06-15");
243
+ expect(typeof result).toBe("string");
244
+ expect(result).toMatch(/\d+\/\d+\/\d+/);
245
+ });
246
+ });
247
+
105
248
  describe("formatDate", () => {
106
249
  test("formats date as YYYY-MM-DD", () => {
107
250
  const date = new Date("2025-06-15T10:30:00");
@@ -123,6 +266,14 @@ describe("isToday", () => {
123
266
  test("accepts string input", () => {
124
267
  expect(isToday(new Date().toISOString())).toBe(true);
125
268
  });
269
+
270
+ test("returns false for invalid date string", () => {
271
+ expect(isToday("invalid")).toBe(false);
272
+ });
273
+
274
+ test("returns false for Invalid Date object", () => {
275
+ expect(isToday(new Date("invalid"))).toBe(false);
276
+ });
126
277
  });
127
278
 
128
279
  describe("isPastDue", () => {
@@ -137,6 +288,11 @@ describe("isPastDue", () => {
137
288
  tomorrow.setDate(tomorrow.getDate() + 1);
138
289
  expect(isPastDue(tomorrow)).toBe(false);
139
290
  });
291
+
292
+ test("returns false for invalid date", () => {
293
+ expect(isPastDue("invalid")).toBe(false);
294
+ expect(isPastDue(new Date("invalid"))).toBe(false);
295
+ });
140
296
  });
141
297
 
142
298
  describe("isWithinDays", () => {
@@ -157,4 +313,17 @@ describe("isWithinDays", () => {
157
313
  yesterday.setDate(yesterday.getDate() - 1);
158
314
  expect(isWithinDays(yesterday, 7)).toBe(false);
159
315
  });
316
+
317
+ test("returns false for invalid date", () => {
318
+ expect(isWithinDays("invalid", 7)).toBe(false);
319
+ expect(isWithinDays(new Date("invalid"), 7)).toBe(false);
320
+ });
321
+
322
+ test("returns false for invalid days parameter", () => {
323
+ const tomorrow = new Date();
324
+ tomorrow.setDate(tomorrow.getDate() + 1);
325
+ expect(isWithinDays(tomorrow, -1)).toBe(false);
326
+ expect(isWithinDays(tomorrow, NaN)).toBe(false);
327
+ expect(isWithinDays(tomorrow, Infinity)).toBe(false);
328
+ });
160
329
  });
package/src/utils/date.ts CHANGED
@@ -1,3 +1,33 @@
1
+ /**
2
+ * Error thrown when date parsing fails
3
+ */
4
+ export class DateParseError extends Error {
5
+ constructor(
6
+ message: string,
7
+ public readonly input: string,
8
+ public readonly reason: DateParseErrorReason
9
+ ) {
10
+ super(message);
11
+ this.name = "DateParseError";
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Reasons why date parsing can fail
17
+ */
18
+ export type DateParseErrorReason =
19
+ | "empty_input"
20
+ | "invalid_format"
21
+ | "invalid_date"
22
+ | "out_of_range";
23
+
24
+ /**
25
+ * Result type for date parsing operations
26
+ */
27
+ export type DateParseResult =
28
+ | { success: true; date: Date }
29
+ | { success: false; error: string; reason: DateParseErrorReason };
30
+
1
31
  /**
2
32
  * Get current ISO timestamp
3
33
  */
@@ -7,53 +37,104 @@ export function now(): string {
7
37
 
8
38
  /**
9
39
  * Parse relative date strings
40
+ * @returns Date object if parsing succeeds, null otherwise
10
41
  */
11
42
  export function parseRelativeDate(input: string): Date | null {
43
+ const result = parseRelativeDateSafe(input);
44
+ return result.success ? result.date : null;
45
+ }
46
+
47
+ /**
48
+ * Parse relative date strings with detailed error information
49
+ *
50
+ * Supported formats:
51
+ * - Relative: "today", "tomorrow", "yesterday", "next week", "in N days"
52
+ * - Korean: "오늘", "내일", "어제", "다음주", "N일 후"
53
+ * - Weekdays: "monday", "tuesday", etc.
54
+ * - ISO format: "YYYY-MM-DD"
55
+ *
56
+ * @returns DateParseResult with either the parsed date or error details
57
+ */
58
+ export function parseRelativeDateSafe(input: string): DateParseResult {
59
+ // Validate input
60
+ if (!input || typeof input !== "string") {
61
+ return {
62
+ success: false,
63
+ error: "Date input is empty or not a string",
64
+ reason: "empty_input",
65
+ };
66
+ }
67
+
68
+ const trimmed = input.trim();
69
+ if (trimmed.length === 0) {
70
+ return {
71
+ success: false,
72
+ error: "Date input is empty after trimming whitespace",
73
+ reason: "empty_input",
74
+ };
75
+ }
76
+
12
77
  const today = new Date();
13
78
  today.setHours(0, 0, 0, 0);
14
79
 
15
- const lower = input.toLowerCase().trim();
80
+ const lower = trimmed.toLowerCase();
16
81
 
17
82
  // Today
18
83
  if (lower === "today" || lower === "오늘") {
19
- return today;
84
+ return { success: true, date: today };
20
85
  }
21
86
 
22
87
  // Tomorrow
23
88
  if (lower === "tomorrow" || lower === "내일") {
24
89
  const d = new Date(today);
25
90
  d.setDate(d.getDate() + 1);
26
- return d;
91
+ return { success: true, date: d };
27
92
  }
28
93
 
29
94
  // Yesterday
30
95
  if (lower === "yesterday" || lower === "어제") {
31
96
  const d = new Date(today);
32
97
  d.setDate(d.getDate() - 1);
33
- return d;
98
+ return { success: true, date: d };
34
99
  }
35
100
 
36
101
  // Next week
37
102
  if (lower === "next week" || lower === "다음주") {
38
103
  const d = new Date(today);
39
104
  d.setDate(d.getDate() + 7);
40
- return d;
105
+ return { success: true, date: d };
41
106
  }
42
107
 
43
108
  // In N days
44
109
  const inDaysMatch = lower.match(/^in (\d+) days?$/);
45
110
  if (inDaysMatch) {
111
+ const days = parseInt(inDaysMatch[1]!, 10);
112
+ if (days > 3650) {
113
+ return {
114
+ success: false,
115
+ error: `Day offset ${days} exceeds maximum of 3650 (10 years)`,
116
+ reason: "out_of_range",
117
+ };
118
+ }
46
119
  const d = new Date(today);
47
- d.setDate(d.getDate() + parseInt(inDaysMatch[1]!, 10));
48
- return d;
120
+ d.setDate(d.getDate() + days);
121
+ return { success: true, date: d };
49
122
  }
50
123
 
51
124
  // N일 후
52
125
  const koreanDaysMatch = lower.match(/^(\d+)일\s*후$/);
53
126
  if (koreanDaysMatch) {
127
+ const days = parseInt(koreanDaysMatch[1]!, 10);
128
+ if (days > 3650) {
129
+ return {
130
+ success: false,
131
+ error: `Day offset ${days} exceeds maximum of 3650 (10 years)`,
132
+ reason: "out_of_range",
133
+ };
134
+ }
54
135
  const d = new Date(today);
55
- d.setDate(d.getDate() + parseInt(koreanDaysMatch[1]!, 10));
56
- return d;
136
+ d.setDate(d.getDate() + days);
137
+ return { success: true, date: d };
57
138
  }
58
139
 
59
140
  // Weekday names
@@ -64,32 +145,75 @@ export function parseRelativeDate(input: string): Date | null {
64
145
  const currentDay = d.getDay();
65
146
  const daysUntil = (weekdayIndex - currentDay + 7) % 7 || 7;
66
147
  d.setDate(d.getDate() + daysUntil);
67
- return d;
148
+ return { success: true, date: d };
68
149
  }
69
150
 
70
151
  // Try parsing as YYYY-MM-DD format (local timezone, no UTC shift)
71
- const isoDateMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
152
+ const isoDateMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
72
153
  if (isoDateMatch) {
73
154
  const [, yearStr, monthStr, dayStr] = isoDateMatch;
74
155
  const year = parseInt(yearStr!, 10);
75
156
  const month = parseInt(monthStr!, 10) - 1; // 0-indexed
76
157
  const day = parseInt(dayStr!, 10);
158
+
159
+ // Validate ranges before creating Date
160
+ if (year < 1900 || year > 2100) {
161
+ return {
162
+ success: false,
163
+ error: `Year ${year} is out of supported range (1900-2100)`,
164
+ reason: "out_of_range",
165
+ };
166
+ }
167
+ if (month < 0 || month > 11) {
168
+ return {
169
+ success: false,
170
+ error: `Month ${monthStr} is invalid (must be 01-12)`,
171
+ reason: "invalid_date",
172
+ };
173
+ }
174
+ if (day < 1 || day > 31) {
175
+ return {
176
+ success: false,
177
+ error: `Day ${dayStr} is invalid (must be 01-31)`,
178
+ reason: "invalid_date",
179
+ };
180
+ }
181
+
77
182
  const d = new Date(year, month, day);
78
183
  // Validate the date is valid (e.g., not Feb 30)
79
184
  if (d.getFullYear() === year && d.getMonth() === month && d.getDate() === day) {
80
- return d;
185
+ return { success: true, date: d };
81
186
  }
187
+
188
+ return {
189
+ success: false,
190
+ error: `Invalid date: ${trimmed} (e.g., February 30 does not exist)`,
191
+ reason: "invalid_date",
192
+ };
193
+ }
194
+
195
+ // Check if it looks like an ISO date but malformed
196
+ if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(trimmed) && !isoDateMatch) {
197
+ return {
198
+ success: false,
199
+ error: `Date "${trimmed}" should use YYYY-MM-DD format with zero-padded month and day`,
200
+ reason: "invalid_format",
201
+ };
82
202
  }
83
203
 
84
204
  // Try parsing other date formats (fallback)
85
- const parsed = new Date(input);
205
+ const parsed = new Date(trimmed);
86
206
  if (!isNaN(parsed.getTime())) {
87
207
  // For non-YYYY-MM-DD formats, normalize to local midnight
88
208
  const d = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
89
- return d;
209
+ return { success: true, date: d };
90
210
  }
91
211
 
92
- return null;
212
+ return {
213
+ success: false,
214
+ error: `Unable to parse "${trimmed}" as a date. Supported formats: "today", "tomorrow", "next week", "in N days", weekday names, or YYYY-MM-DD`,
215
+ reason: "invalid_format",
216
+ };
93
217
  }
94
218
 
95
219
  /**
@@ -103,13 +227,57 @@ export function formatDate(date: Date): string {
103
227
  return `${year}-${month}-${day}`;
104
228
  }
105
229
 
230
+ /**
231
+ * Parse a date string and return a validated Date object
232
+ * @throws {DateParseError} if the string cannot be parsed as a valid date
233
+ */
234
+ export function parseDateString(input: string): Date {
235
+ if (!input || typeof input !== "string" || input.trim().length === 0) {
236
+ throw new DateParseError("Date string is empty or not a string", input ?? "", "empty_input");
237
+ }
238
+
239
+ const d = new Date(input);
240
+ if (isNaN(d.getTime())) {
241
+ throw new DateParseError(
242
+ `Unable to parse "${input}" as a date`,
243
+ input,
244
+ "invalid_format"
245
+ );
246
+ }
247
+
248
+ return d;
249
+ }
250
+
251
+ /**
252
+ * Check if a Date object is valid (not Invalid Date)
253
+ */
254
+ export function isValidDate(date: Date): boolean {
255
+ return date instanceof Date && !isNaN(date.getTime());
256
+ }
257
+
106
258
  /**
107
259
  * Format date for display using system locale
108
260
  * Use this for showing dates to users
109
261
  * Output: YY/MM/DD, MM/DD/YY, DD/MM/YY (depends on locale)
262
+ * @throws {DateParseError} if input string cannot be parsed
110
263
  */
111
264
  export function formatDisplayDate(date: Date | string): string {
112
- const d = typeof date === "string" ? new Date(date) : date;
265
+ let d: Date;
266
+
267
+ if (typeof date === "string") {
268
+ d = parseDateString(date);
269
+ } else {
270
+ d = date;
271
+ }
272
+
273
+ if (!isValidDate(d)) {
274
+ throw new DateParseError(
275
+ "Invalid Date object provided",
276
+ String(date),
277
+ "invalid_date"
278
+ );
279
+ }
280
+
113
281
  const parts = new Intl.DateTimeFormat(undefined, {
114
282
  year: "2-digit",
115
283
  month: "2-digit",
@@ -122,11 +290,28 @@ export function formatDisplayDate(date: Date | string): string {
122
290
  .join("/");
123
291
  }
124
292
 
293
+ /**
294
+ * Safely convert string or Date to Date, returning null for invalid inputs
295
+ */
296
+ function toDate(date: Date | string): Date | null {
297
+ if (date instanceof Date) {
298
+ return isValidDate(date) ? date : null;
299
+ }
300
+ if (typeof date === "string" && date.trim().length > 0) {
301
+ const d = new Date(date);
302
+ return isValidDate(d) ? d : null;
303
+ }
304
+ return null;
305
+ }
306
+
125
307
  /**
126
308
  * Check if a date is today
309
+ * Returns false for invalid dates (does not throw)
127
310
  */
128
311
  export function isToday(date: Date | string): boolean {
129
- const d = typeof date === "string" ? new Date(date) : date;
312
+ const d = toDate(date);
313
+ if (!d) return false;
314
+
130
315
  const today = new Date();
131
316
  return (
132
317
  d.getFullYear() === today.getFullYear() &&
@@ -137,9 +322,12 @@ export function isToday(date: Date | string): boolean {
137
322
 
138
323
  /**
139
324
  * Check if a date is before today
325
+ * Returns false for invalid dates (does not throw)
140
326
  */
141
327
  export function isPastDue(date: Date | string): boolean {
142
- const d = typeof date === "string" ? new Date(date) : date;
328
+ const d = toDate(date);
329
+ if (!d) return false;
330
+
143
331
  const today = new Date();
144
332
  today.setHours(0, 0, 0, 0);
145
333
  return d < today;
@@ -147,9 +335,16 @@ export function isPastDue(date: Date | string): boolean {
147
335
 
148
336
  /**
149
337
  * Check if a date is within the next N days
338
+ * Returns false for invalid dates or invalid days parameter (does not throw)
150
339
  */
151
340
  export function isWithinDays(date: Date | string, days: number): boolean {
152
- const d = typeof date === "string" ? new Date(date) : date;
341
+ const d = toDate(date);
342
+ if (!d) return false;
343
+
344
+ if (typeof days !== "number" || !Number.isFinite(days) || days < 0) {
345
+ return false;
346
+ }
347
+
153
348
  const today = new Date();
154
349
  today.setHours(0, 0, 0, 0);
155
350
  const future = new Date(today);