@lotics/ui 13.8.0 → 13.9.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/docs/composition.md +9 -0
- package/package.json +1 -1
- package/src/format_date.test.ts +46 -0
- package/src/format_date.ts +102 -12
- package/src/format_date_negative_zone.test.ts +26 -0
package/docs/composition.md
CHANGED
|
@@ -213,6 +213,15 @@ a dashboard. Card stat rails use `KPICard`.
|
|
|
213
213
|
re-implementation. (Exempt: a component's own internal chrome — a calendar's header/weekday/a11y
|
|
214
214
|
labels, a gantt axis — renders its own set.)
|
|
215
215
|
|
|
216
|
+
**Reduced-precision date values.** A `date` field may store an ISO 8601 truncated value —
|
|
217
|
+
`"2026-05"` (month) or `"2026"` (year) — for a document that carries only that precision.
|
|
218
|
+
`formatDate` renders only the parts present: a month value → `05/2026` (`date` style) /
|
|
219
|
+
`thg 5, 2026` (`medium`), a year value → `2026`; no day is fabricated and `time` is ignored
|
|
220
|
+
(a calendar period has no clock). **`toISODate(value)`** (`Date`/ISO → the picker/workflow date
|
|
221
|
+
string) PRESERVES those partials verbatim — it returns `"2026-05"` / `"2026"` unchanged, so a
|
|
222
|
+
form round-trip never expands them to a fabricated `"2026-05-01"`. Comparisons and sorting treat
|
|
223
|
+
a partial as its period start (`"2026" < "2026-05" < "2026-05-01"`).
|
|
224
|
+
|
|
216
225
|
## Every number is a door
|
|
217
226
|
|
|
218
227
|
Except the KPI strip: a component that summarizes records leads to the records behind it when
|
package/package.json
CHANGED
package/src/format_date.test.ts
CHANGED
|
@@ -63,6 +63,34 @@ describe("formatDate", () => {
|
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
describe("formatDate — reduced precision", () => {
|
|
67
|
+
test("month value renders MM/YYYY (numeric, locale order), no fabricated day", () => {
|
|
68
|
+
expect(formatDate("2026-05", { locale: "vi-VN" })).toBe("05/2026");
|
|
69
|
+
expect(formatDate("2026-05", { locale: "en-US" })).toBe("05/2026");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("year value renders just the year", () => {
|
|
73
|
+
expect(formatDate("2026", { locale: "vi-VN" })).toBe("2026");
|
|
74
|
+
expect(formatDate("2026", { format: "medium", locale: "en-US" })).toBe("2026");
|
|
75
|
+
expect(formatDate("2026", { format: "monthYear", locale: "en-US" })).toBe("2026");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("compact drops the year on a month value", () => {
|
|
79
|
+
expect(formatDate("2026-05", { locale: "vi-VN", compact: true })).toBe("05");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("readable styles drop the day for a month value", () => {
|
|
83
|
+
expect(formatDate("2026-05", { format: "monthYear", locale: "en-US" })).toBe("May 2026");
|
|
84
|
+
expect(formatDate("2026-05", { format: "medium", locale: "en-US" })).toBe("May 2026");
|
|
85
|
+
expect(formatDate("2026-05", { format: "long", locale: "en-US" })).toBe("May 2026");
|
|
86
|
+
expect(formatDate("2026-05", { format: "dayMonth", locale: "en-US" })).toBe("May");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("time is ignored for a reduced-precision value (a period has no clock)", () => {
|
|
90
|
+
expect(formatDate("2026-05", { locale: "vi-VN", time: true })).toBe("05/2026");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
66
94
|
describe("parseDate", () => {
|
|
67
95
|
test("parses an ISO datetime to local wall-clock", () => {
|
|
68
96
|
const d = parseDate("2026-05-22T14:30");
|
|
@@ -81,6 +109,17 @@ describe("parseDate", () => {
|
|
|
81
109
|
expect(parseDate(null)).toBeNull();
|
|
82
110
|
expect(parseDate("")).toBeNull();
|
|
83
111
|
});
|
|
112
|
+
|
|
113
|
+
test("parses a reduced-precision value to its period start", () => {
|
|
114
|
+
const month = parseDate("2026-05");
|
|
115
|
+
expect(month?.getFullYear()).toBe(2026);
|
|
116
|
+
expect(month?.getMonth()).toBe(4);
|
|
117
|
+
expect(month?.getDate()).toBe(1);
|
|
118
|
+
const year = parseDate("2026");
|
|
119
|
+
expect(year?.getFullYear()).toBe(2026);
|
|
120
|
+
expect(year?.getMonth()).toBe(0);
|
|
121
|
+
expect(year?.getDate()).toBe(1);
|
|
122
|
+
});
|
|
84
123
|
});
|
|
85
124
|
|
|
86
125
|
describe("toISODate", () => {
|
|
@@ -89,4 +128,11 @@ describe("toISODate", () => {
|
|
|
89
128
|
expect(toISODate("2026-05-22T14:30")).toBe("2026-05-22");
|
|
90
129
|
expect(toISODate(null)).toBe("");
|
|
91
130
|
});
|
|
131
|
+
|
|
132
|
+
test("PRESERVES reduced precision verbatim — never fabricates a day", () => {
|
|
133
|
+
expect(toISODate("2026-05")).toBe("2026-05");
|
|
134
|
+
expect(toISODate("2026")).toBe("2026");
|
|
135
|
+
// Whitespace-trimmed but otherwise unchanged.
|
|
136
|
+
expect(toISODate(" 2026-05 ")).toBe("2026-05");
|
|
137
|
+
});
|
|
92
138
|
});
|
package/src/format_date.ts
CHANGED
|
@@ -24,6 +24,9 @@ export interface FormatDateOptions {
|
|
|
24
24
|
emptyLabel?: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** The precision a value carries — a `date` field may store a reduced ISO form. */
|
|
28
|
+
type DatePrecision = "year" | "month" | "day";
|
|
29
|
+
|
|
27
30
|
const READABLE_OPTS: Record<"medium" | "long" | "dayMonth" | "monthYear", Intl.DateTimeFormatOptions> = {
|
|
28
31
|
medium: { day: "numeric", month: "short", year: "numeric" },
|
|
29
32
|
long: { day: "numeric", month: "long", year: "numeric" },
|
|
@@ -31,32 +34,67 @@ const READABLE_OPTS: Record<"medium" | "long" | "dayMonth" | "monthYear", Intl.D
|
|
|
31
34
|
monthYear: { month: "long", year: "numeric" },
|
|
32
35
|
};
|
|
33
36
|
|
|
37
|
+
const ISO_YEAR = /^(\d{4})$/;
|
|
38
|
+
const ISO_YEAR_MONTH = /^(\d{4})-(\d{2})$/;
|
|
34
39
|
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
35
40
|
|
|
36
41
|
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
|
-
* Parse a Date or a canonical timezone-naive ISO value as local wall-clock
|
|
40
|
-
* `new Date("2024-03-15")` parses date-only strings as **UTC**
|
|
41
|
-
* shifts under local formatting — a phantom time and an off-by-one date in
|
|
42
|
-
* zones. Building the Date from its parts keeps it naive, matching how a date
|
|
43
|
-
* same value. A
|
|
44
|
-
*
|
|
44
|
+
* Parse a Date or a canonical timezone-naive ISO value as local wall-clock, carrying the
|
|
45
|
+
* PRECISION it was written at. `new Date("2024-03-15")` parses date-only strings as **UTC**
|
|
46
|
+
* midnight, which then shifts under local formatting — a phantom time and an off-by-one date in
|
|
47
|
+
* negative-offset zones. Building the Date from its parts keeps it naive, matching how a date
|
|
48
|
+
* picker reads the same value. A reduced-precision value ("2024-03" / "2024") anchors at its
|
|
49
|
+
* period start (day/month → 1) but is tagged so the formatter never fabricates the missing parts.
|
|
50
|
+
* A `Date` passes through at day precision; non-ISO strings fall back to the native parser.
|
|
51
|
+
* Returns `null` for null / empty / unparseable input.
|
|
45
52
|
*/
|
|
46
|
-
|
|
53
|
+
function parseNaive(value: Date | string | null | undefined): { date: Date; precision: DatePrecision } | null {
|
|
47
54
|
if (value == null) return null;
|
|
48
|
-
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
55
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : { date: value, precision: "day" };
|
|
49
56
|
const s = value.trim();
|
|
50
57
|
if (!s) return null;
|
|
58
|
+
|
|
59
|
+
const y = ISO_YEAR.exec(s);
|
|
60
|
+
if (y) {
|
|
61
|
+
const d = new Date(Number(y[1]), 0, 1);
|
|
62
|
+
return Number.isNaN(d.getTime()) ? null : { date: d, precision: "year" };
|
|
63
|
+
}
|
|
64
|
+
const ym = ISO_YEAR_MONTH.exec(s);
|
|
65
|
+
if (ym) {
|
|
66
|
+
const d = new Date(Number(ym[1]), Number(ym[2]) - 1, 1);
|
|
67
|
+
return Number.isNaN(d.getTime()) ? null : { date: d, precision: "month" };
|
|
68
|
+
}
|
|
51
69
|
const m = ISO_DATE_TIME.exec(s);
|
|
52
70
|
const d = m
|
|
53
71
|
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0))
|
|
54
72
|
: new Date(s);
|
|
55
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
73
|
+
return Number.isNaN(d.getTime()) ? null : { date: d, precision: "day" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A Date or ISO value → a local wall-clock `Date` (period start for a reduced-precision value).
|
|
78
|
+
* "" / null / unparseable → `null`. Use {@link formatDate} for display; this is for callers that
|
|
79
|
+
* need the `Date` itself (a picker anchor, an axis position).
|
|
80
|
+
*/
|
|
81
|
+
export function parseDate(value: Date | string | null | undefined): Date | null {
|
|
82
|
+
return parseNaive(value)?.date ?? null;
|
|
56
83
|
}
|
|
57
84
|
|
|
58
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* A Date or ISO value → the `DatePicker` / workflow date value. "" when empty.
|
|
87
|
+
*
|
|
88
|
+
* **Reduced precision is PRESERVED, not expanded.** A `date` field may carry "YYYY-MM" or "YYYY";
|
|
89
|
+
* those are returned verbatim so a round-trip through a form never fabricates a day (writing back
|
|
90
|
+
* "2026-05-01" for a value the user entered as "2026-05" would corrupt it). A full value (Date,
|
|
91
|
+
* `yyyy-MM-dd`, or a datetime) reassembles as "yyyy-MM-dd".
|
|
92
|
+
*/
|
|
59
93
|
export function toISODate(value: Date | string | null | undefined): string {
|
|
94
|
+
if (typeof value === "string") {
|
|
95
|
+
const s = value.trim();
|
|
96
|
+
if (ISO_YEAR.test(s) || ISO_YEAR_MONTH.test(s)) return s;
|
|
97
|
+
}
|
|
60
98
|
const d = parseDate(value);
|
|
61
99
|
if (!d) return "";
|
|
62
100
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
@@ -71,11 +109,24 @@ export function toISODate(value: Date | string | null | undefined): string {
|
|
|
71
109
|
* year on the numeric `date` style. Never hand-roll a date with `padStart` / `getMonth` / a raw
|
|
72
110
|
* `Intl.DateTimeFormat` for VALUE display — call this. (Component-internal chrome is exempt — see
|
|
73
111
|
* {@link DateFormatStyle}.)
|
|
112
|
+
*
|
|
113
|
+
* **Reduced-precision values render only the parts they carry** — a month-year value shows
|
|
114
|
+
* `05/2026` (numeric) / `thg 5, 2026` (readable), a bare year shows `2026`; no day is fabricated
|
|
115
|
+
* and `time` is ignored (a calendar period has no clock).
|
|
74
116
|
*/
|
|
75
117
|
export function formatDate(value: Date | string | null | undefined, options: FormatDateOptions = {}): string {
|
|
76
118
|
const { format = "date", time = false, locale = "vi-VN", compact = false, emptyLabel = "" } = options;
|
|
77
|
-
const
|
|
78
|
-
if (!
|
|
119
|
+
const parsed = parseNaive(value);
|
|
120
|
+
if (!parsed) return emptyLabel;
|
|
121
|
+
const { date, precision } = parsed;
|
|
122
|
+
|
|
123
|
+
// Reduced precision: no day, no time. A bare year is locale-invariant.
|
|
124
|
+
if (precision === "year") {
|
|
125
|
+
return String(date.getFullYear());
|
|
126
|
+
}
|
|
127
|
+
if (precision === "month") {
|
|
128
|
+
return formatMonthYear(date, format, locale, compact, emptyLabel);
|
|
129
|
+
}
|
|
79
130
|
|
|
80
131
|
let dateStr: string;
|
|
81
132
|
if (format === "date") {
|
|
@@ -107,3 +158,42 @@ export function formatDate(value: Date | string | null | undefined, options: For
|
|
|
107
158
|
// Time-first: a stable 24h "HH:mm " prepended (no locale comma, no AM/PM), per the product convention.
|
|
108
159
|
return time ? `${pad2(date.getHours())}:${pad2(date.getMinutes())} ${dateStr}` : dateStr;
|
|
109
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render a month-year value (no day) in the given style: numeric `MM/YYYY` in locale part order
|
|
164
|
+
* for the `date` style (year dropped when `compact`), a word month for the readable styles (the
|
|
165
|
+
* style's `day` part is omitted). `monthYear` is sentence-cased like the full-precision path.
|
|
166
|
+
*/
|
|
167
|
+
function formatMonthYear(
|
|
168
|
+
date: Date,
|
|
169
|
+
format: DateFormatStyle,
|
|
170
|
+
locale: string,
|
|
171
|
+
compact: boolean,
|
|
172
|
+
emptyLabel: string,
|
|
173
|
+
): string {
|
|
174
|
+
if (format === "date") {
|
|
175
|
+
let parts: Intl.DateTimeFormatPart[];
|
|
176
|
+
try {
|
|
177
|
+
parts = new Intl.DateTimeFormat(locale, { month: "2-digit", year: "numeric" }).formatToParts(date);
|
|
178
|
+
} catch {
|
|
179
|
+
return emptyLabel;
|
|
180
|
+
}
|
|
181
|
+
return parts
|
|
182
|
+
.filter((p) => p.type === "month" || (!compact && p.type === "year"))
|
|
183
|
+
.map((p) => p.value)
|
|
184
|
+
.join("/");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const base = READABLE_OPTS[format];
|
|
188
|
+
const monthOpts: Intl.DateTimeFormatOptions = base.year
|
|
189
|
+
? { month: base.month, year: base.year }
|
|
190
|
+
: { month: base.month };
|
|
191
|
+
let dateStr: string;
|
|
192
|
+
try {
|
|
193
|
+
dateStr = new Intl.DateTimeFormat(locale, monthOpts).format(date);
|
|
194
|
+
} catch {
|
|
195
|
+
return emptyLabel;
|
|
196
|
+
}
|
|
197
|
+
if (format === "monthYear") dateStr = dateStr.charAt(0).toUpperCase() + dateStr.slice(1);
|
|
198
|
+
return dateStr;
|
|
199
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Pin a NEGATIVE-offset zone (UTC-5) before importing the formatter. The old
|
|
2
|
+
// fall-through `new Date("2026-05")` parses as UTC midnight, which in a
|
|
3
|
+
// negative-offset zone lands on the PREVIOUS month ("2026-04-30T19:00"), drifting
|
|
4
|
+
// a month-precision value back a month. The naive part-based construction must
|
|
5
|
+
// keep "2026-05" on May regardless of the runtime zone.
|
|
6
|
+
process.env.TZ = "America/New_York";
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { formatDate, toISODate } from "./format_date";
|
|
10
|
+
|
|
11
|
+
describe("formatDate — reduced precision in a negative-offset zone", () => {
|
|
12
|
+
test("a month value does not drift to the previous month", () => {
|
|
13
|
+
expect(formatDate("2026-05", { locale: "en-US" })).toBe("05/2026");
|
|
14
|
+
// January is the adversarial case — a backward drift would land in Dec 2025.
|
|
15
|
+
expect(formatDate("2026-01", { locale: "en-US" })).toBe("01/2026");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("a year value stays on its year", () => {
|
|
19
|
+
expect(formatDate("2026", { locale: "en-US" })).toBe("2026");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("toISODate preserves the partial (no drift, no fabricated day)", () => {
|
|
23
|
+
expect(toISODate("2026-01")).toBe("2026-01");
|
|
24
|
+
expect(toISODate("2026")).toBe("2026");
|
|
25
|
+
});
|
|
26
|
+
});
|