@frockbot/client-ui 0.3.8 → 0.3.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/client-ui",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,7 @@
12
12
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/client-core": "0.3.8",
15
+ "@frockbot/client-core": "0.3.10",
16
16
  "markdown-it": "15.0.1",
17
17
  "vue": "3.5.41"
18
18
  },
package/src/index.ts CHANGED
@@ -15,3 +15,11 @@ export { default as UiMarkdown } from "./UiMarkdown.vue";
15
15
  export { renderMarkdown } from "./markdown.js";
16
16
  export { default as UiSkeleton } from "./UiSkeleton.vue";
17
17
  export { createClientSurfaceRegistry } from "./surfaces.js";
18
+ export {
19
+ browserTimeZoneV1,
20
+ formatDayV1,
21
+ formatMomentV1,
22
+ formatRelativeMomentV1,
23
+ formatTimeOfDayV1,
24
+ type UiMomentOptionsV1,
25
+ } from "./time.js";
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ browserTimeZoneV1,
4
+ formatDayV1,
5
+ formatMomentV1,
6
+ formatRelativeMomentV1,
7
+ formatTimeOfDayV1,
8
+ } from "./time.js";
9
+
10
+ const SYDNEY = { timeZone: "Australia/Sydney" } as const;
11
+
12
+ describe("formatMomentV1", () => {
13
+ test("renders a durable ISO moment in the reader's zone, day first", () => {
14
+ // The panel used to render this exact string verbatim.
15
+ expect(formatMomentV1("2026-09-03T12:08:28.834Z", SYDNEY)).toBe(
16
+ "3 Sep 2026, 10:08pm",
17
+ );
18
+ });
19
+
20
+ test("never renders the US month-first order", () => {
21
+ const formatted = formatMomentV1("2026-09-03T11:59:51.000Z", SYDNEY);
22
+ expect(formatted).not.toContain("9/3/2026");
23
+ expect(formatted.startsWith("3 Sep 2026")).toBe(true);
24
+ });
25
+
26
+ test("renders the same characters on every ICU build", () => {
27
+ // The month abbreviation and the am/pm marker are CLDR data, so a locale's
28
+ // own rendering of them moves between platform versions: the same instant
29
+ // read out of `en-AU` is "3 Sep 2026" under one ICU and "3 Sept 2026"
30
+ // under the next, with a narrow no-break space before the marker on some
31
+ // builds. This module assembles the string itself so two people reading
32
+ // the same Bot see the same text.
33
+ const formatted = formatMomentV1("2026-09-03T12:08:28.834Z", SYDNEY);
34
+ expect(formatted).toBe("3 Sep 2026, 10:08pm");
35
+ expect(formatted).not.toContain("Sept");
36
+ expect(formatted).toMatch(
37
+ /^\d{1,2} [A-Z][a-z]{2} \d{4}, \d{1,2}:\d{2}(am|pm)$/u,
38
+ );
39
+ // No exotic whitespace anywhere: one plain space after the day and the
40
+ // month, and nothing at all before the marker.
41
+ expect(/[^\u0020\w:,]/u.test(formatted)).toBe(false);
42
+ });
43
+
44
+ test("returns unparseable text untouched rather than 'Invalid Date'", () => {
45
+ expect(formatMomentV1("not a moment", SYDNEY)).toBe("not a moment");
46
+ expect(formatRelativeMomentV1("", SYDNEY)).toBe("");
47
+ });
48
+ });
49
+
50
+ describe("formatTimeOfDayV1", () => {
51
+ test("reads midnight and noon as 12, not as 0", () => {
52
+ // A 24-hour clock reads midnight as `0` and some ICU builds as `24`;
53
+ // neither is an hour anybody writes down.
54
+ expect(formatTimeOfDayV1("2026-09-03T14:00:00.000Z", SYDNEY)).toBe(
55
+ "12:00am",
56
+ );
57
+ expect(formatTimeOfDayV1("2026-09-03T02:00:00.000Z", SYDNEY)).toBe(
58
+ "12:00pm",
59
+ );
60
+ });
61
+
62
+ test("is 12-hour with a lowercase marker and no leading zero", () => {
63
+ expect(formatTimeOfDayV1("2026-09-03T23:30:00.000Z", SYDNEY)).toBe(
64
+ "9:30am",
65
+ );
66
+ expect(formatTimeOfDayV1("2026-09-03T04:05:00.000Z", SYDNEY)).toBe(
67
+ "2:05pm",
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("formatDayV1", () => {
73
+ test("abbreviates the month", () => {
74
+ expect(formatDayV1("2026-09-03T12:00:00.000Z", SYDNEY)).toBe("3 Sep 2026");
75
+ });
76
+ });
77
+
78
+ describe("formatRelativeMomentV1", () => {
79
+ const now = new Date("2026-09-03T12:00:00.000Z");
80
+
81
+ test("reads a fresh run relatively and an old one absolutely", () => {
82
+ expect(
83
+ formatRelativeMomentV1("2026-09-03T11:59:30.000Z", { ...SYDNEY, now }),
84
+ ).toBe("just now");
85
+ expect(
86
+ formatRelativeMomentV1("2026-09-03T11:45:00.000Z", { ...SYDNEY, now }),
87
+ ).toBe("15 min ago");
88
+ expect(
89
+ formatRelativeMomentV1("2026-09-03T09:00:00.000Z", { ...SYDNEY, now }),
90
+ ).toBe("3 hours ago");
91
+ expect(
92
+ formatRelativeMomentV1("2026-08-30T09:00:00.000Z", { ...SYDNEY, now }),
93
+ ).toBe("30 Aug 2026, 7:00pm");
94
+ });
95
+
96
+ test("a future moment is absolute — 'in a while' is not a next run", () => {
97
+ expect(
98
+ formatRelativeMomentV1("2026-09-03T12:30:00.000Z", { ...SYDNEY, now }),
99
+ ).toBe("3 Sep 2026, 10:30pm");
100
+ });
101
+ });
102
+
103
+ describe("browserTimeZoneV1", () => {
104
+ test("names a zone the form can default to", () => {
105
+ expect(browserTimeZoneV1().length).toBeGreaterThan(0);
106
+ });
107
+ });
package/src/time.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * One place the whole client turns a durable ISO moment into something a
3
+ * person reads.
4
+ *
5
+ * Every durable record carries UTC ISO text, because that is the only form two
6
+ * machines can agree on. A person reading the panel is not two machines: they
7
+ * want the moment in their own day. Panels used to render the ISO string
8
+ * verbatim — "Next run 2026-09-03T12:08:28.834Z" — which is precise and
9
+ * unreadable, and the one panel that did format dates picked the US order for
10
+ * an Australian reader. So this module, and nothing else, decides the shape.
11
+ *
12
+ * The shape is assembled here from numeric parts rather than taken from a
13
+ * locale's own rendering. `Intl` is asked only for the two things it alone
14
+ * knows — which calendar day and clock hour a UTC instant falls on in a given
15
+ * zone — and every visible character after that is ours. It has to be: the
16
+ * month abbreviation and the am/pm marker are CLDR data, so the same code
17
+ * renders "3 Sep 2026" under one ICU version and "3 Sept 2026" under the next,
18
+ * and a narrow no-break space before the marker under some builds and a plain
19
+ * one under others. That is invisible until a test written on one machine
20
+ * fails on another, and worse, it means two people reading the same Bot see
21
+ * different text. A house style that varies by machine is not a house style.
22
+ */
23
+
24
+ /** The month names this module renders. Ours, not the platform's. */
25
+ const MONTHS = [
26
+ "Jan",
27
+ "Feb",
28
+ "Mar",
29
+ "Apr",
30
+ "May",
31
+ "Jun",
32
+ "Jul",
33
+ "Aug",
34
+ "Sep",
35
+ "Oct",
36
+ "Nov",
37
+ "Dec",
38
+ ] as const;
39
+
40
+ /**
41
+ * A locale chosen for one property only: it yields plain numeric parts for
42
+ * `year`/`month`/`day`/`hour`/`minute` in every ICU build. Nothing of its
43
+ * ordering, separators or names reaches the screen — `formatToParts` is read
44
+ * by name and the string is rebuilt below.
45
+ */
46
+ const PARTS_LOCALE = "en-GB";
47
+
48
+ /** The reader's own zone, or UTC where the environment will not say. */
49
+ export function browserTimeZoneV1(): string {
50
+ try {
51
+ const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
52
+ return typeof zone === "string" && zone.length > 0 ? zone : "UTC";
53
+ } catch {
54
+ return "UTC";
55
+ }
56
+ }
57
+
58
+ export interface UiMomentOptionsV1 {
59
+ /** The zone to read the moment in. Defaults to the reader's own. */
60
+ timeZone?: string;
61
+ }
62
+
63
+ interface MomentPartsV1 {
64
+ day: number;
65
+ month: number;
66
+ year: number;
67
+ hour: number;
68
+ minute: number;
69
+ }
70
+
71
+ /**
72
+ * The calendar day and clock time one UTC instant falls on in one zone.
73
+ *
74
+ * Read as a 24-hour clock and converted below, because `hour12` is where the
75
+ * am/pm marker and its spacing come from, and those are exactly the parts that
76
+ * differ between ICU builds.
77
+ */
78
+ function momentParts(
79
+ iso: string,
80
+ options: UiMomentOptionsV1,
81
+ ): MomentPartsV1 | undefined {
82
+ const parsed = Date.parse(iso);
83
+ if (!Number.isFinite(parsed)) return undefined;
84
+ let parts: Intl.DateTimeFormatPart[];
85
+ try {
86
+ parts = new Intl.DateTimeFormat(PARTS_LOCALE, {
87
+ year: "numeric",
88
+ month: "numeric",
89
+ day: "numeric",
90
+ hour: "numeric",
91
+ minute: "2-digit",
92
+ hour12: false,
93
+ timeZone: options.timeZone ?? browserTimeZoneV1(),
94
+ }).formatToParts(new Date(parsed));
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ const read = (type: Intl.DateTimeFormatPartTypes): number => {
99
+ const found = parts.find((part) => part.type === type);
100
+ return found === undefined ? Number.NaN : Number(found.value);
101
+ };
102
+ const moment = {
103
+ day: read("day"),
104
+ month: read("month"),
105
+ year: read("year"),
106
+ // Some builds render midnight under a 24-hour clock as `24`.
107
+ hour: read("hour") % 24,
108
+ minute: read("minute"),
109
+ };
110
+ return Object.values(moment).every((value) => Number.isFinite(value))
111
+ ? moment
112
+ : undefined;
113
+ }
114
+
115
+ /** `9:30am`, lowercase, no leading zero on the hour. */
116
+ export function formatTimeOfDayV1(
117
+ iso: string,
118
+ options: UiMomentOptionsV1 = {},
119
+ ): string {
120
+ const moment = momentParts(iso, options);
121
+ if (!moment) return iso;
122
+ const marker = moment.hour < 12 ? "am" : "pm";
123
+ const hour = moment.hour % 12 === 0 ? 12 : moment.hour % 12;
124
+ return `${hour}:${String(moment.minute).padStart(2, "0")}${marker}`;
125
+ }
126
+
127
+ /** `3 Sep 2026` — day first, never the US month-first order. */
128
+ export function formatDayV1(
129
+ iso: string,
130
+ options: UiMomentOptionsV1 = {},
131
+ ): string {
132
+ const moment = momentParts(iso, options);
133
+ if (!moment) return iso;
134
+ const month = MONTHS[moment.month - 1];
135
+ if (month === undefined) return iso;
136
+ return `${moment.day} ${month} ${moment.year}`;
137
+ }
138
+
139
+ /**
140
+ * `3 Sep 2026, 9:30am`. An unparseable value is returned untouched rather than
141
+ * rendered as "Invalid Date": the durable text is at least true.
142
+ */
143
+ export function formatMomentV1(
144
+ iso: string,
145
+ options: UiMomentOptionsV1 = {},
146
+ ): string {
147
+ const moment = momentParts(iso, options);
148
+ if (!moment) return iso;
149
+ return `${formatDayV1(iso, options)}, ${formatTimeOfDayV1(iso, options)}`;
150
+ }
151
+
152
+ /**
153
+ * A moment relative to now while that is the more useful reading, and the
154
+ * absolute one after. A run log is scanned for "did it just fire?"; a run from
155
+ * last month is scanned for which day it was.
156
+ */
157
+ export function formatRelativeMomentV1(
158
+ iso: string,
159
+ options: UiMomentOptionsV1 & { now?: Date } = {},
160
+ ): string {
161
+ const parsed = Date.parse(iso);
162
+ if (!Number.isFinite(parsed)) return iso;
163
+ const now = (options.now ?? new Date()).getTime();
164
+ const elapsed = now - parsed;
165
+ if (elapsed < 0) return formatMomentV1(iso, options);
166
+ if (elapsed < 60_000) return "just now";
167
+ if (elapsed < 3_600_000) {
168
+ const minutes = Math.floor(elapsed / 60_000);
169
+ return `${minutes} min ago`;
170
+ }
171
+ if (elapsed < 86_400_000) {
172
+ const hours = Math.floor(elapsed / 3_600_000);
173
+ return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
174
+ }
175
+ return formatMomentV1(iso, options);
176
+ }