@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.
- package/dist/algorithms/critical-path.d.ts.map +1 -1
- package/dist/algorithms/critical-path.js +2 -14
- package/dist/algorithms/critical-path.js.map +1 -1
- package/dist/algorithms/dependency-integrity.d.ts +8 -0
- package/dist/algorithms/dependency-integrity.d.ts.map +1 -1
- package/dist/algorithms/dependency-integrity.js +42 -24
- package/dist/algorithms/dependency-integrity.js.map +1 -1
- package/dist/algorithms/dependency-integrity.test.d.ts +2 -0
- package/dist/algorithms/dependency-integrity.test.d.ts.map +1 -0
- package/dist/algorithms/dependency-integrity.test.js +309 -0
- package/dist/algorithms/dependency-integrity.test.js.map +1 -0
- package/dist/algorithms/tech-analysis.d.ts +5 -5
- package/dist/algorithms/tech-analysis.d.ts.map +1 -1
- package/dist/algorithms/tech-analysis.js +65 -17
- package/dist/algorithms/tech-analysis.js.map +1 -1
- package/dist/algorithms/topological-sort.d.ts.map +1 -1
- package/dist/algorithms/topological-sort.js +1 -56
- package/dist/algorithms/topological-sort.js.map +1 -1
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/project.d.ts +6 -6
- package/dist/schemas/state.d.ts +17 -0
- package/dist/schemas/state.d.ts.map +1 -0
- package/dist/schemas/state.js +17 -0
- package/dist/schemas/state.js.map +1 -0
- package/dist/schemas/task.d.ts +13 -4
- package/dist/schemas/task.d.ts.map +1 -1
- package/dist/schemas/task.js +3 -0
- package/dist/schemas/task.js.map +1 -1
- package/dist/schemas/view.d.ts +4 -4
- package/dist/utils/dashboard-renderer.d.ts +3 -0
- package/dist/utils/dashboard-renderer.d.ts.map +1 -1
- package/dist/utils/dashboard-renderer.js +12 -13
- package/dist/utils/dashboard-renderer.js.map +1 -1
- package/dist/utils/dashboard-renderer.test.d.ts +2 -0
- package/dist/utils/dashboard-renderer.test.d.ts.map +1 -0
- package/dist/utils/dashboard-renderer.test.js +777 -0
- package/dist/utils/dashboard-renderer.test.js.map +1 -0
- package/dist/utils/date.d.ts +49 -0
- package/dist/utils/date.d.ts.map +1 -1
- package/dist/utils/date.js +174 -19
- package/dist/utils/date.js.map +1 -1
- package/dist/utils/date.test.js +139 -1
- package/dist/utils/date.test.js.map +1 -1
- package/dist/utils/hierarchy.d.ts +1 -1
- package/dist/utils/hierarchy.d.ts.map +1 -1
- package/dist/utils/hierarchy.js +15 -5
- package/dist/utils/hierarchy.js.map +1 -1
- package/dist/utils/hierarchy.test.d.ts +2 -0
- package/dist/utils/hierarchy.test.d.ts.map +1 -0
- package/dist/utils/hierarchy.test.js +351 -0
- package/dist/utils/hierarchy.test.js.map +1 -0
- package/dist/utils/id.js +1 -1
- package/dist/utils/id.js.map +1 -1
- package/dist/utils/index.d.ts +3 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -2
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/natural-language.d.ts.map +1 -1
- package/dist/utils/natural-language.js +7 -0
- package/dist/utils/natural-language.js.map +1 -1
- package/dist/utils/natural-language.test.js +24 -0
- package/dist/utils/natural-language.test.js.map +1 -1
- package/dist/utils/priority-queue.d.ts +17 -0
- package/dist/utils/priority-queue.d.ts.map +1 -0
- package/dist/utils/priority-queue.js +62 -0
- package/dist/utils/priority-queue.js.map +1 -0
- package/dist/utils/projection.d.ts +9 -0
- package/dist/utils/projection.d.ts.map +1 -1
- package/dist/utils/projection.js +37 -0
- package/dist/utils/projection.js.map +1 -1
- package/dist/utils/terminal-ui.d.ts +5 -0
- package/dist/utils/terminal-ui.d.ts.map +1 -1
- package/dist/utils/terminal-ui.js +88 -11
- package/dist/utils/terminal-ui.js.map +1 -1
- package/dist/utils/terminal-ui.test.d.ts +2 -0
- package/dist/utils/terminal-ui.test.d.ts.map +1 -0
- package/dist/utils/terminal-ui.test.js +683 -0
- package/dist/utils/terminal-ui.test.js.map +1 -0
- package/package.json +1 -1
- package/src/algorithms/critical-path.ts +6 -14
- package/src/algorithms/dependency-integrity.test.ts +348 -0
- package/src/algorithms/dependency-integrity.ts +41 -26
- package/src/algorithms/tech-analysis.ts +86 -18
- package/src/algorithms/topological-sort.ts +1 -62
- package/src/schemas/index.ts +3 -0
- package/src/schemas/state.ts +23 -0
- package/src/schemas/task.ts +3 -0
- package/src/utils/dashboard-renderer.test.ts +981 -0
- package/src/utils/dashboard-renderer.ts +14 -15
- package/src/utils/date.test.ts +170 -1
- package/src/utils/date.ts +214 -19
- package/src/utils/hierarchy.test.ts +411 -0
- package/src/utils/hierarchy.ts +22 -5
- package/src/utils/id.ts +1 -1
- package/src/utils/index.ts +17 -1
- package/src/utils/natural-language.test.ts +28 -0
- package/src/utils/natural-language.ts +8 -0
- package/src/utils/priority-queue.ts +68 -0
- package/src/utils/projection.ts +46 -2
- package/src/utils/terminal-ui.test.ts +831 -0
- 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
|
-
|
|
507
|
-
|
|
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, {
|
package/src/utils/date.test.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
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 =
|
|
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() +
|
|
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() +
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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);
|