@lotics/ui 2.6.0 → 2.6.1
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/package.json +2 -1
- package/src/format_date.test.ts +64 -0
- package/src/format_date.ts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./kpi_card": "./src/kpi_card.tsx",
|
|
32
32
|
"./kpi_strip": "./src/kpi_strip.tsx",
|
|
33
33
|
"./empty_state": "./src/empty_state.tsx",
|
|
34
|
+
"./format_date": "./src/format_date.ts",
|
|
34
35
|
"./format_money": "./src/format_money.ts",
|
|
35
36
|
"./kanban": "./src/kanban/index.ts",
|
|
36
37
|
"./calendar": "./src/calendar/index.ts",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { formatDate, parseDate, toISODate } from "./format_date";
|
|
3
|
+
|
|
4
|
+
describe("formatDate", () => {
|
|
5
|
+
test("formats an ISO date in the home market (dd/MM/yyyy)", () => {
|
|
6
|
+
expect(formatDate("2026-05-22", { locale: "vi-VN" })).toBe("22/05/2026");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("formats a Date object, not just an ISO string", () => {
|
|
10
|
+
expect(formatDate(new Date(2026, 4, 22), { locale: "vi-VN" })).toBe("22/05/2026");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("compact drops the year (dd/MM)", () => {
|
|
14
|
+
expect(formatDate("2026-05-22", { locale: "vi-VN", compact: true })).toBe("22/05");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("datetime includes 24h time", () => {
|
|
18
|
+
expect(formatDate("2026-05-22T14:30", { locale: "vi-VN", format: "datetime" })).toBe("22/05/2026 14:30");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("compact datetime drops the year, keeps the time", () => {
|
|
22
|
+
expect(formatDate("2026-05-22T09:05", { locale: "vi-VN", format: "datetime", compact: true })).toBe("22/05 09:05");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("date-only ISO does not drift across local timezone (wall-clock parse)", () => {
|
|
26
|
+
// `new Date('2026-01-01')` is UTC midnight; in negative-offset zones it formats as Dec 31.
|
|
27
|
+
expect(formatDate("2026-01-01", { locale: "vi-VN" })).toBe("01/01/2026");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("empty input returns the empty label", () => {
|
|
31
|
+
expect(formatDate(null)).toBe("");
|
|
32
|
+
expect(formatDate("")).toBe("");
|
|
33
|
+
expect(formatDate(undefined, { emptyLabel: "—" })).toBe("—");
|
|
34
|
+
expect(formatDate("not-a-date", { emptyLabel: "—" })).toBe("—");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("parseDate", () => {
|
|
39
|
+
test("parses an ISO datetime to local wall-clock", () => {
|
|
40
|
+
const d = parseDate("2026-05-22T14:30");
|
|
41
|
+
expect(d).not.toBeNull();
|
|
42
|
+
expect(d?.getFullYear()).toBe(2026);
|
|
43
|
+
expect(d?.getMonth()).toBe(4);
|
|
44
|
+
expect(d?.getDate()).toBe(22);
|
|
45
|
+
expect(d?.getHours()).toBe(14);
|
|
46
|
+
expect(d?.getMinutes()).toBe(30);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("passes a valid Date through and rejects an invalid one", () => {
|
|
50
|
+
const d = new Date(2026, 0, 1);
|
|
51
|
+
expect(parseDate(d)).toBe(d);
|
|
52
|
+
expect(parseDate(new Date("nonsense"))).toBeNull();
|
|
53
|
+
expect(parseDate(null)).toBeNull();
|
|
54
|
+
expect(parseDate("")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("toISODate", () => {
|
|
59
|
+
test("Date or ISO value → yyyy-MM-dd", () => {
|
|
60
|
+
expect(toISODate(new Date(2026, 4, 22))).toBe("2026-05-22");
|
|
61
|
+
expect(toISODate("2026-05-22T14:30")).toBe("2026-05-22");
|
|
62
|
+
expect(toISODate(null)).toBe("");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type DateFormatStyle = "date" | "datetime";
|
|
2
|
+
|
|
3
|
+
export interface FormatDateOptions {
|
|
4
|
+
/** "date" → 22/05/2026 · "datetime" → 22/05/2026 14:30. Default "date". */
|
|
5
|
+
format?: DateFormatStyle;
|
|
6
|
+
/** BCP-47 locale. Defaults to the product's home market, "vi-VN". */
|
|
7
|
+
locale?: string;
|
|
8
|
+
/** Drop the year — "22/05" instead of "22/05/2026" — for dense rows / timelines. */
|
|
9
|
+
compact?: boolean;
|
|
10
|
+
/** Rendered for null / empty / unparseable input. Default "". */
|
|
11
|
+
emptyLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
15
|
+
|
|
16
|
+
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a Date or a canonical timezone-naive ISO value as local wall-clock.
|
|
20
|
+
* `new Date("2024-03-15")` parses date-only strings as **UTC** midnight, which then
|
|
21
|
+
* shifts under local formatting — a phantom time and an off-by-one date in negative-offset
|
|
22
|
+
* zones. Building the Date from its parts keeps it naive, matching how a date picker reads the
|
|
23
|
+
* same value. A `Date` passes through; non-ISO strings fall back to the native parser. Returns
|
|
24
|
+
* `null` for null / empty / unparseable input.
|
|
25
|
+
*/
|
|
26
|
+
export function parseDate(value: Date | string | null | undefined): Date | null {
|
|
27
|
+
if (value == null) return null;
|
|
28
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
29
|
+
const s = value.trim();
|
|
30
|
+
if (!s) return null;
|
|
31
|
+
const m = ISO_DATE_TIME.exec(s);
|
|
32
|
+
const d = m
|
|
33
|
+
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0))
|
|
34
|
+
: new Date(s);
|
|
35
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A Date or ISO value → "yyyy-MM-dd" (the `DatePicker` / workflow date value). "" when empty. */
|
|
39
|
+
export function toISODate(value: Date | string | null | undefined): string {
|
|
40
|
+
const d = parseDate(value);
|
|
41
|
+
if (!d) return "";
|
|
42
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* THE date formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string and
|
|
47
|
+
* returns a localized display string; defaults to the home market: `22/05/2026` (date) /
|
|
48
|
+
* `22/05/2026 14:30` (datetime). `compact` drops the year for dense rows (`22/05`). Never
|
|
49
|
+
* hand-roll dd/MM with `padStart` / `getMonth` — call this.
|
|
50
|
+
*/
|
|
51
|
+
export function formatDate(value: Date | string | null | undefined, options: FormatDateOptions = {}): string {
|
|
52
|
+
const { format = "date", locale = "vi-VN", compact = false, emptyLabel = "" } = options;
|
|
53
|
+
const date = parseDate(value);
|
|
54
|
+
if (!date) return emptyLabel;
|
|
55
|
+
// Use Intl only for the locale-aware part ORDER (dd/MM for vi-VN, MM/dd for en-US), then
|
|
56
|
+
// reassemble with a consistent "/" — Intl's own separator is inconsistent across CLDR (vi-VN
|
|
57
|
+
// uses "/" with a year but "-" without). The time is appended as a stable 24h " HH:mm" (no
|
|
58
|
+
// locale comma, no AM/PM), matching the compact data convention.
|
|
59
|
+
let parts: Intl.DateTimeFormatPart[];
|
|
60
|
+
try {
|
|
61
|
+
parts = new Intl.DateTimeFormat(locale, { day: "2-digit", month: "2-digit", year: "numeric" }).formatToParts(date);
|
|
62
|
+
} catch {
|
|
63
|
+
return emptyLabel;
|
|
64
|
+
}
|
|
65
|
+
let out = parts
|
|
66
|
+
.filter((p) => p.type === "day" || p.type === "month" || (!compact && p.type === "year"))
|
|
67
|
+
.map((p) => p.value)
|
|
68
|
+
.join("/");
|
|
69
|
+
if (format === "datetime") out += ` ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
|
70
|
+
return out;
|
|
71
|
+
}
|