@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.
@@ -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
+ }