@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "13.8.0",
3
+ "version": "13.9.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -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
  });
@@ -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** midnight, which then
41
- * shifts under local formatting — a phantom time and an off-by-one date in negative-offset
42
- * zones. Building the Date from its parts keeps it naive, matching how a date picker reads the
43
- * same value. A `Date` passes through; non-ISO strings fall back to the native parser. Returns
44
- * `null` for null / empty / unparseable input.
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
- export function parseDate(value: Date | string | null | undefined): Date | null {
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
- /** A Date or ISO value → "yyyy-MM-dd" (the `DatePicker` / workflow date value). "" when empty. */
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 date = parseDate(value);
78
- if (!date) return emptyLabel;
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
+ });