@kahitsan/ksui 0.10.2 → 0.12.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/README.md +19 -21
- package/package.json +3 -7
- package/src/components/base/CameraCapture.tsx +2 -2
- package/src/components/base/DataTable.tsx +930 -0
- package/src/components/base/DatePicker.tsx +990 -0
- package/src/components/base/ExistingAttachmentTile.tsx +3 -3
- package/src/components/base/ImageCropper.tsx +3 -3
- package/src/components/base/Modal.tsx +198 -0
- package/src/components/composite/ComboBox.tsx +2 -2
- package/src/components/composite/FormActions.tsx +4 -4
- package/src/components/composite/MarkdownNotes.tsx +8 -11
- package/src/index.ts +55 -14
- package/src/utils/accounts-index.tsx +7 -6
- package/src/utils/confirm.tsx +84 -0
- package/src/utils/dom.ts +197 -0
- package/src/utils/highlight.tsx +67 -0
- package/src/utils/integration.ts +49 -0
- package/src/utils/parse-date.ts +595 -0
- package/host-ui.d.ts +0 -145
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// Natural-language date parser + date string formatters.
|
|
2
|
+
//
|
|
3
|
+
// Ported verbatim from the host kserp DatePicker's `parse-date.ts` so ksui's
|
|
4
|
+
// DatePicker carries no host dependency. Pure functions, no UI, no framework —
|
|
5
|
+
// depends on nothing. Lives in utils/ (not components/) because it renders
|
|
6
|
+
// nothing; the DatePicker component calls it.
|
|
7
|
+
//
|
|
8
|
+
// Handles: "dec 3", "december 3", "decem 3", "now", "today", "yesterday",
|
|
9
|
+
// "last week", "last month", "next friday", typo correction, and optional time.
|
|
10
|
+
// Returns { date: string (YYYY-MM-DD), time?: string (HH:MM) } or null.
|
|
11
|
+
|
|
12
|
+
export interface ParsedDate {
|
|
13
|
+
date: string; // YYYY-MM-DD
|
|
14
|
+
time?: string; // HH:MM (24h)
|
|
15
|
+
label?: string; // human-readable label for what was parsed
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Month names + fuzzy matching ──────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const MONTHS = [
|
|
21
|
+
"january",
|
|
22
|
+
"february",
|
|
23
|
+
"march",
|
|
24
|
+
"april",
|
|
25
|
+
"may",
|
|
26
|
+
"june",
|
|
27
|
+
"july",
|
|
28
|
+
"august",
|
|
29
|
+
"september",
|
|
30
|
+
"october",
|
|
31
|
+
"november",
|
|
32
|
+
"december",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
const MONTH_ABBREVS: Record<string, number> = {
|
|
36
|
+
jan: 0,
|
|
37
|
+
feb: 1,
|
|
38
|
+
mar: 2,
|
|
39
|
+
apr: 3,
|
|
40
|
+
may: 4,
|
|
41
|
+
jun: 5,
|
|
42
|
+
jul: 6,
|
|
43
|
+
aug: 7,
|
|
44
|
+
sep: 8,
|
|
45
|
+
oct: 9,
|
|
46
|
+
nov: 10,
|
|
47
|
+
dec: 11,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Day-of-week names for "next friday" etc.
|
|
51
|
+
const WEEKDAYS = [
|
|
52
|
+
"sunday",
|
|
53
|
+
"monday",
|
|
54
|
+
"tuesday",
|
|
55
|
+
"wednesday",
|
|
56
|
+
"thursday",
|
|
57
|
+
"friday",
|
|
58
|
+
"saturday",
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
const WEEKDAY_ABBREVS: Record<string, number> = {
|
|
62
|
+
sun: 0,
|
|
63
|
+
mon: 1,
|
|
64
|
+
tue: 2,
|
|
65
|
+
wed: 3,
|
|
66
|
+
thu: 4,
|
|
67
|
+
fri: 5,
|
|
68
|
+
sat: 6,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ── Levenshtein distance (for typo correction) ───────────────────────────────
|
|
72
|
+
|
|
73
|
+
function levenshtein(a: string, b: string): number {
|
|
74
|
+
const m = a.length;
|
|
75
|
+
const n = b.length;
|
|
76
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
77
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
78
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
79
|
+
for (let i = 1; i <= m; i++) {
|
|
80
|
+
for (let j = 1; j <= n; j++) {
|
|
81
|
+
dp[i][j] = Math.min(
|
|
82
|
+
dp[i - 1][j] + 1,
|
|
83
|
+
dp[i][j - 1] + 1,
|
|
84
|
+
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return dp[m][n];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Match input against month names with typo tolerance.
|
|
93
|
+
* Tries: exact prefix match first, then fuzzy match (Levenshtein ≤ 2).
|
|
94
|
+
*/
|
|
95
|
+
function matchMonth(input: string): number | null {
|
|
96
|
+
const lower = input.toLowerCase();
|
|
97
|
+
|
|
98
|
+
// Exact abbreviation
|
|
99
|
+
if (MONTH_ABBREVS[lower] !== undefined) return MONTH_ABBREVS[lower];
|
|
100
|
+
|
|
101
|
+
// Prefix match (e.g. "decem" → december, "janu" → january)
|
|
102
|
+
for (let i = 0; i < MONTHS.length; i++) {
|
|
103
|
+
if (MONTHS[i].startsWith(lower) && lower.length >= 3) return i;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Fuzzy match — only if input is 4+ chars to avoid false positives
|
|
107
|
+
if (lower.length >= 4) {
|
|
108
|
+
let bestIdx = -1;
|
|
109
|
+
let bestDist = Infinity;
|
|
110
|
+
for (let i = 0; i < MONTHS.length; i++) {
|
|
111
|
+
// Compare against full name and against same-length prefix
|
|
112
|
+
const full = MONTHS[i];
|
|
113
|
+
const prefix = full.slice(0, lower.length);
|
|
114
|
+
const distFull = levenshtein(lower, full);
|
|
115
|
+
const distPrefix = levenshtein(lower, prefix);
|
|
116
|
+
const dist = Math.min(distFull, distPrefix);
|
|
117
|
+
if (dist < bestDist) {
|
|
118
|
+
bestDist = dist;
|
|
119
|
+
bestIdx = i;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Allow up to 2 edits
|
|
123
|
+
if (bestDist <= 2) return bestIdx;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Match input against weekday names with typo tolerance.
|
|
131
|
+
*/
|
|
132
|
+
function matchWeekday(input: string): number | null {
|
|
133
|
+
const lower = input.toLowerCase();
|
|
134
|
+
|
|
135
|
+
if (WEEKDAY_ABBREVS[lower] !== undefined) return WEEKDAY_ABBREVS[lower];
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < WEEKDAYS.length; i++) {
|
|
138
|
+
if (WEEKDAYS[i].startsWith(lower) && lower.length >= 3) return i;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (lower.length >= 4) {
|
|
142
|
+
let bestIdx = -1;
|
|
143
|
+
let bestDist = Infinity;
|
|
144
|
+
for (let i = 0; i < WEEKDAYS.length; i++) {
|
|
145
|
+
const full = WEEKDAYS[i];
|
|
146
|
+
const prefix = full.slice(0, lower.length);
|
|
147
|
+
const dist = Math.min(levenshtein(lower, full), levenshtein(lower, prefix));
|
|
148
|
+
if (dist < bestDist) {
|
|
149
|
+
bestDist = dist;
|
|
150
|
+
bestIdx = i;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (bestDist <= 2) return bestIdx;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Time parsing ──────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function parseTime(input: string): { hours: number; minutes: number } | null {
|
|
162
|
+
const cleaned = input.trim().toLowerCase();
|
|
163
|
+
const patterns: [RegExp, (m: RegExpMatchArray) => { hours: number; minutes: number } | null][] = [
|
|
164
|
+
// 5pm, 5:30pm, 12:45am
|
|
165
|
+
[
|
|
166
|
+
/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/,
|
|
167
|
+
(m) => {
|
|
168
|
+
let h = parseInt(m[1], 10);
|
|
169
|
+
const min = parseInt(m[2] || "0", 10);
|
|
170
|
+
const period = m[3];
|
|
171
|
+
if (period === "pm" && h !== 12) h += 12;
|
|
172
|
+
if (period === "am" && h === 12) h = 0;
|
|
173
|
+
return h < 24 && min < 60 ? { hours: h, minutes: min } : null;
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
// 17:00, 09:30
|
|
177
|
+
[
|
|
178
|
+
/^(\d{1,2}):(\d{2})$/,
|
|
179
|
+
(m) => {
|
|
180
|
+
const h = parseInt(m[1], 10);
|
|
181
|
+
const min = parseInt(m[2], 10);
|
|
182
|
+
return h < 24 && min < 60 ? { hours: h, minutes: min } : null;
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
// 1730
|
|
186
|
+
[
|
|
187
|
+
/^(\d{2})(\d{2})$/,
|
|
188
|
+
(m) => {
|
|
189
|
+
const h = parseInt(m[1], 10);
|
|
190
|
+
const min = parseInt(m[2], 10);
|
|
191
|
+
return h < 24 && min < 60 ? { hours: h, minutes: min } : null;
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
for (const [re, handler] of patterns) {
|
|
197
|
+
const match = cleaned.match(re);
|
|
198
|
+
if (match) return handler(match);
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function toDateStr(d: Date): string {
|
|
206
|
+
const y = d.getFullYear();
|
|
207
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
208
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
209
|
+
return `${y}-${m}-${day}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function toTimeStr(h: number, m: number): string {
|
|
213
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Fuzzy match relative keywords. Returns a handler or null.
|
|
218
|
+
*/
|
|
219
|
+
function matchRelative(word: string): (() => Date) | null {
|
|
220
|
+
const candidates: [string[], () => Date][] = [
|
|
221
|
+
[["now", "today", "tdy"], () => new Date()],
|
|
222
|
+
[
|
|
223
|
+
["yesterday", "yest", "yesterdy", "yestrday"],
|
|
224
|
+
() => {
|
|
225
|
+
const d = new Date();
|
|
226
|
+
d.setDate(d.getDate() - 1);
|
|
227
|
+
return d;
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
[
|
|
231
|
+
["tomorrow", "tmr", "tmrw", "tomorow", "tommorow", "tmrow"],
|
|
232
|
+
() => {
|
|
233
|
+
const d = new Date();
|
|
234
|
+
d.setDate(d.getDate() + 1);
|
|
235
|
+
return d;
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
const lower = word.toLowerCase();
|
|
241
|
+
|
|
242
|
+
for (const [aliases, fn] of candidates) {
|
|
243
|
+
for (const alias of aliases) {
|
|
244
|
+
if (alias === lower) return fn;
|
|
245
|
+
// Fuzzy: allow 1-2 char edits for words 4+ chars
|
|
246
|
+
if (lower.length >= 4 && levenshtein(lower, alias) <= 2) return fn;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Main parser ──────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function relativeWeekday(now: Date, direction: number, wd: number): Date {
|
|
256
|
+
const d = new Date(now);
|
|
257
|
+
const currentDay = d.getDay();
|
|
258
|
+
let diff: number;
|
|
259
|
+
if (direction === -1) {
|
|
260
|
+
diff = currentDay - wd;
|
|
261
|
+
if (diff <= 0) diff += 7;
|
|
262
|
+
d.setDate(d.getDate() - diff);
|
|
263
|
+
} else {
|
|
264
|
+
diff = wd - currentDay;
|
|
265
|
+
if (diff <= 0) diff += 7;
|
|
266
|
+
d.setDate(d.getDate() + diff);
|
|
267
|
+
}
|
|
268
|
+
return d;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Handle "last X" / "next X" patterns ("last week", "next friday", "last month").
|
|
273
|
+
* Returns a ParsedDate when tokens[0] is "last"/"next" and a subject resolves,
|
|
274
|
+
* otherwise null (caller falls through to other patterns).
|
|
275
|
+
*/
|
|
276
|
+
function parseRelativeDirection(tokens: string[], now: Date): ParsedDate | null {
|
|
277
|
+
const direction = tokens[0] === "last" ? -1 : 1;
|
|
278
|
+
const subject = tokens.slice(1).join(" ");
|
|
279
|
+
|
|
280
|
+
let datePart: Date | null = null;
|
|
281
|
+
let label = "";
|
|
282
|
+
|
|
283
|
+
if (subject === "week") {
|
|
284
|
+
const d = new Date(now);
|
|
285
|
+
d.setDate(d.getDate() + direction * 7);
|
|
286
|
+
datePart = d;
|
|
287
|
+
label = `${tokens[0]} week`;
|
|
288
|
+
} else if (subject === "month") {
|
|
289
|
+
const d = new Date(now);
|
|
290
|
+
d.setMonth(d.getMonth() + direction);
|
|
291
|
+
datePart = d;
|
|
292
|
+
label = `${tokens[0]} month`;
|
|
293
|
+
} else if (subject === "year") {
|
|
294
|
+
const d = new Date(now);
|
|
295
|
+
d.setFullYear(d.getFullYear() + direction);
|
|
296
|
+
datePart = d;
|
|
297
|
+
label = `${tokens[0]} year`;
|
|
298
|
+
} else {
|
|
299
|
+
// "last friday", "next monday"
|
|
300
|
+
const wd = matchWeekday(subject);
|
|
301
|
+
if (wd !== null) {
|
|
302
|
+
datePart = relativeWeekday(now, direction, wd);
|
|
303
|
+
label = `${tokens[0]} ${WEEKDAYS[wd]}`;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!datePart) return null;
|
|
308
|
+
|
|
309
|
+
// Check for trailing time token
|
|
310
|
+
let timePart: { hours: number; minutes: number } | null = null;
|
|
311
|
+
if (tokens.length > 2) {
|
|
312
|
+
const timeCandidate = tokens.slice(2).join("");
|
|
313
|
+
timePart = parseTime(timeCandidate);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const result: ParsedDate = { date: toDateStr(datePart), label };
|
|
317
|
+
if (timePart) result.time = toTimeStr(timePart.hours, timePart.minutes);
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Parse the optional trailing year/time tokens shared by the "month day" and
|
|
323
|
+
* "day month" patterns. Starts at startIdx and scans to the end of tokens.
|
|
324
|
+
*/
|
|
325
|
+
function parseTrailingYearTime(
|
|
326
|
+
tokens: string[],
|
|
327
|
+
startIdx: number,
|
|
328
|
+
defaultYear: number,
|
|
329
|
+
): { year: number; timePart: { hours: number; minutes: number } | null } {
|
|
330
|
+
let year = defaultYear;
|
|
331
|
+
let timePart: { hours: number; minutes: number } | null = null;
|
|
332
|
+
let tokenIdx = startIdx;
|
|
333
|
+
|
|
334
|
+
while (tokenIdx < tokens.length) {
|
|
335
|
+
const tok = tokens[tokenIdx];
|
|
336
|
+
// 4-digit year
|
|
337
|
+
if (/^\d{4}$/.test(tok)) {
|
|
338
|
+
year = parseInt(tok, 10);
|
|
339
|
+
tokenIdx++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
// Try as time
|
|
343
|
+
const t = parseTime(tok);
|
|
344
|
+
if (t) {
|
|
345
|
+
timePart = t;
|
|
346
|
+
tokenIdx++;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
// Try joining remaining as time (e.g. "7" "pm" → "7pm")
|
|
350
|
+
const joined = tokens.slice(tokenIdx).join("");
|
|
351
|
+
const t2 = parseTime(joined);
|
|
352
|
+
if (t2) {
|
|
353
|
+
timePart = t2;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
tokenIdx++;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { year, timePart };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Handle "month day [year] [time]" pattern ("dec 3", "december 3 2025", "dec 3 7pm").
|
|
364
|
+
* Returns a ParsedDate when tokens[0] matches a month and the day is valid,
|
|
365
|
+
* otherwise null.
|
|
366
|
+
*/
|
|
367
|
+
function parseMonthDay(tokens: string[], now: Date): ParsedDate | null {
|
|
368
|
+
const monthIdx = matchMonth(tokens[0]);
|
|
369
|
+
if (monthIdx === null) return null;
|
|
370
|
+
|
|
371
|
+
let day = 0;
|
|
372
|
+
let tokenIdx = 1;
|
|
373
|
+
|
|
374
|
+
// Parse day
|
|
375
|
+
if (tokenIdx < tokens.length) {
|
|
376
|
+
const dayMatch = tokens[tokenIdx].match(/^(\d{1,2})(st|nd|rd|th)?$/);
|
|
377
|
+
if (dayMatch) {
|
|
378
|
+
day = parseInt(dayMatch[1], 10);
|
|
379
|
+
tokenIdx++;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// If no day provided, default to 1st
|
|
384
|
+
if (day === 0) day = 1;
|
|
385
|
+
|
|
386
|
+
// Parse optional year or time
|
|
387
|
+
const { year, timePart } = parseTrailingYearTime(tokens, tokenIdx, now.getFullYear());
|
|
388
|
+
|
|
389
|
+
// Validate day
|
|
390
|
+
const maxDay = new Date(year, monthIdx + 1, 0).getDate();
|
|
391
|
+
if (day < 1 || day > maxDay) return null;
|
|
392
|
+
|
|
393
|
+
const datePart = new Date(year, monthIdx, day);
|
|
394
|
+
const yearSuffix = year !== now.getFullYear() ? `, ${year}` : "";
|
|
395
|
+
const label = `${MONTHS[monthIdx].slice(0, 3)} ${day}${yearSuffix}`;
|
|
396
|
+
|
|
397
|
+
const result: ParsedDate = { date: toDateStr(datePart), label };
|
|
398
|
+
if (timePart) result.time = toTimeStr(timePart.hours, timePart.minutes);
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Handle "day month [year] [time]" pattern ("3 dec", "15 january").
|
|
404
|
+
* Returns a ParsedDate when tokens[0] is a day and tokens[1] a month, else null.
|
|
405
|
+
*/
|
|
406
|
+
function parseDayMonth(tokens: string[], now: Date): ParsedDate | null {
|
|
407
|
+
const dayMatch = tokens[0].match(/^(\d{1,2})(st|nd|rd|th)?$/);
|
|
408
|
+
if (!dayMatch) return null;
|
|
409
|
+
|
|
410
|
+
const mIdx = matchMonth(tokens[1]);
|
|
411
|
+
if (mIdx === null) return null;
|
|
412
|
+
|
|
413
|
+
const day = parseInt(dayMatch[1], 10);
|
|
414
|
+
const { year, timePart } = parseTrailingYearTime(tokens, 2, now.getFullYear());
|
|
415
|
+
|
|
416
|
+
const maxDay = new Date(year, mIdx + 1, 0).getDate();
|
|
417
|
+
if (day < 1 || day > maxDay) return null;
|
|
418
|
+
|
|
419
|
+
const datePart = new Date(year, mIdx, day);
|
|
420
|
+
const yearSuffix = year !== now.getFullYear() ? `, ${year}` : "";
|
|
421
|
+
const label = `${MONTHS[mIdx].slice(0, 3)} ${day}${yearSuffix}`;
|
|
422
|
+
const result: ParsedDate = { date: toDateStr(datePart), label };
|
|
423
|
+
if (timePart) result.time = toTimeStr(timePart.hours, timePart.minutes);
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function parseDateInput(input: string, ref?: Date): ParsedDate | null {
|
|
428
|
+
const trimmed = input.trim();
|
|
429
|
+
if (!trimmed) return null;
|
|
430
|
+
|
|
431
|
+
const now = ref ?? new Date();
|
|
432
|
+
|
|
433
|
+
// Try ISO date first: YYYY-MM-DD
|
|
434
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
435
|
+
const d = new Date(trimmed + "T00:00:00");
|
|
436
|
+
if (!isNaN(d.getTime())) return { date: trimmed, label: formatLabel(d) };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Split into tokens, separating time-like tokens
|
|
440
|
+
const tokens = trimmed
|
|
441
|
+
.toLowerCase()
|
|
442
|
+
.split(/[\s,]+/)
|
|
443
|
+
.filter(Boolean);
|
|
444
|
+
if (tokens.length === 0) return null;
|
|
445
|
+
|
|
446
|
+
// ── Single word: relative or month-only ──
|
|
447
|
+
|
|
448
|
+
// Check "last X" / "next X" patterns
|
|
449
|
+
if (tokens.length >= 2 && (tokens[0] === "last" || tokens[0] === "next")) {
|
|
450
|
+
const relResult = parseRelativeDirection(tokens, now);
|
|
451
|
+
if (relResult) return relResult;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Single token: "now", "today", "yesterday", "tomorrow" ──
|
|
455
|
+
const relSingle = parseRelativeSingle(tokens);
|
|
456
|
+
if (relSingle) return relSingle;
|
|
457
|
+
|
|
458
|
+
// ── Month + day [+ year] [+ time] pattern ──
|
|
459
|
+
// "dec 3", "december 3", "decem 3", "dec 3 2025", "dec 3 7pm"
|
|
460
|
+
const monthDayResult = parseMonthDay(tokens, now);
|
|
461
|
+
if (monthDayResult) return monthDayResult;
|
|
462
|
+
|
|
463
|
+
// ── "day month" pattern (e.g. "3 dec", "15 january") ──
|
|
464
|
+
if (tokens.length >= 2) {
|
|
465
|
+
const dayMonthResult = parseDayMonth(tokens, now);
|
|
466
|
+
if (dayMonthResult) return dayMonthResult;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── MM/DD or MM/DD/YYYY ──
|
|
470
|
+
const slashResult = parseSlashDate(trimmed, now);
|
|
471
|
+
if (slashResult) return slashResult;
|
|
472
|
+
|
|
473
|
+
// ── Weekday-only: "friday", "monday" → next occurrence ──
|
|
474
|
+
return parseWeekdayOnly(tokens, now);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Handle a single relative keyword ("now"/"today"/"yesterday"/"tomorrow")
|
|
479
|
+
* optionally followed by a time token. Returns null when tokens[0] is not a
|
|
480
|
+
* relative keyword (or there are too many tokens).
|
|
481
|
+
*/
|
|
482
|
+
function parseRelativeSingle(tokens: string[]): ParsedDate | null {
|
|
483
|
+
const relFn = matchRelative(tokens[0]);
|
|
484
|
+
if (!relFn || tokens.length > 2) return null;
|
|
485
|
+
|
|
486
|
+
const datePart = relFn();
|
|
487
|
+
const label = tokens[0];
|
|
488
|
+
const timePart = tokens[1] ? parseTime(tokens[1]) : null;
|
|
489
|
+
|
|
490
|
+
const result: ParsedDate = { date: toDateStr(datePart), label };
|
|
491
|
+
if (timePart) result.time = toTimeStr(timePart.hours, timePart.minutes);
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Handle "MM/DD" or "MM/DD/YYYY" (also "." / "-" separators). Returns null when
|
|
497
|
+
* the input doesn't match or the parsed date is out of range.
|
|
498
|
+
*/
|
|
499
|
+
function parseSlashDate(trimmed: string, now: Date): ParsedDate | null {
|
|
500
|
+
const slashMatch = trimmed.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
|
|
501
|
+
if (!slashMatch) return null;
|
|
502
|
+
|
|
503
|
+
const m = parseInt(slashMatch[1], 10) - 1;
|
|
504
|
+
const d = parseInt(slashMatch[2], 10);
|
|
505
|
+
let y = slashMatch[3] ? parseInt(slashMatch[3], 10) : now.getFullYear();
|
|
506
|
+
if (y < 100) y += 2000;
|
|
507
|
+
if (m < 0 || m >= 12 || d < 1 || d > new Date(y, m + 1, 0).getDate()) return null;
|
|
508
|
+
|
|
509
|
+
const datePart = new Date(y, m, d);
|
|
510
|
+
const yearSuffix = y !== now.getFullYear() ? `, ${y}` : "";
|
|
511
|
+
const label = `${MONTHS[m].slice(0, 3)} ${d}${yearSuffix}`;
|
|
512
|
+
return { date: toDateStr(datePart), label };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Handle a bare weekday ("friday", "monday") → its next occurrence, optionally
|
|
517
|
+
* followed by a time token. Returns null when tokens[0] is not a weekday.
|
|
518
|
+
*/
|
|
519
|
+
function parseWeekdayOnly(tokens: string[], now: Date): ParsedDate | null {
|
|
520
|
+
const wd = matchWeekday(tokens[0]);
|
|
521
|
+
if (wd === null || tokens.length > 2) return null;
|
|
522
|
+
|
|
523
|
+
const d = new Date(now);
|
|
524
|
+
const currentDay = d.getDay();
|
|
525
|
+
let diff = wd - currentDay;
|
|
526
|
+
if (diff <= 0) diff += 7;
|
|
527
|
+
d.setDate(d.getDate() + diff);
|
|
528
|
+
const label = WEEKDAYS[wd];
|
|
529
|
+
const timePart = tokens[1] ? parseTime(tokens[1]) : null;
|
|
530
|
+
|
|
531
|
+
const result: ParsedDate = { date: toDateStr(d), label };
|
|
532
|
+
if (timePart) result.time = toTimeStr(timePart.hours, timePart.minutes);
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── Format helpers ───────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
function formatLabel(d: Date): string {
|
|
539
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Normalize any date-ish string to YYYY-MM-DD.
|
|
544
|
+
* Handles: "2025-07-26", "2025-07-26T00:00:00.000Z", "2025-07-26T16:30:00", etc.
|
|
545
|
+
*/
|
|
546
|
+
export function normalizeDate(dateStr: string): string {
|
|
547
|
+
// Already YYYY-MM-DD
|
|
548
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
|
|
549
|
+
// Full ISO or datetime — extract the date part
|
|
550
|
+
const match = dateStr.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
551
|
+
if (match) return match[1];
|
|
552
|
+
// Fallback: parse with Date and extract
|
|
553
|
+
const d = new Date(dateStr);
|
|
554
|
+
if (!isNaN(d.getTime())) return toDateStr(d);
|
|
555
|
+
return dateStr;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function formatDateDisplay(dateStr: string): string {
|
|
559
|
+
const normalized = normalizeDate(dateStr);
|
|
560
|
+
const d = new Date(normalized + "T00:00:00");
|
|
561
|
+
if (isNaN(d.getTime())) return dateStr; // can't parse, return as-is
|
|
562
|
+
const now = new Date();
|
|
563
|
+
const today = toDateStr(now);
|
|
564
|
+
const yesterday = new Date(now);
|
|
565
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
566
|
+
const yesterdayStr = toDateStr(yesterday);
|
|
567
|
+
const tomorrow = new Date(now);
|
|
568
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
569
|
+
const tomorrowStr = toDateStr(tomorrow);
|
|
570
|
+
|
|
571
|
+
if (normalized === today) return "Today";
|
|
572
|
+
if (normalized === yesterdayStr) return "Yesterday";
|
|
573
|
+
if (normalized === tomorrowStr) return "Tomorrow";
|
|
574
|
+
|
|
575
|
+
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
|
|
576
|
+
if (d.getFullYear() !== now.getFullYear()) opts.year = "numeric";
|
|
577
|
+
return d.toLocaleDateString("en-US", opts);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Full date with year for the editable input: "Apr 12, 2026" */
|
|
581
|
+
export function formatDateEditable(dateStr: string): string {
|
|
582
|
+
const normalized = normalizeDate(dateStr);
|
|
583
|
+
const d = new Date(normalized + "T00:00:00");
|
|
584
|
+
if (isNaN(d.getTime())) return dateStr;
|
|
585
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function formatTimeDisplay(time: string): string {
|
|
589
|
+
const [hStr, mStr] = time.split(":");
|
|
590
|
+
const h = parseInt(hStr, 10);
|
|
591
|
+
const m = parseInt(mStr, 10);
|
|
592
|
+
const period = h >= 12 ? "pm" : "am";
|
|
593
|
+
const displayH = h % 12 || 12;
|
|
594
|
+
return m === 0 ? `${displayH}${period}` : `${displayH}:${mStr}${period}`;
|
|
595
|
+
}
|