@lotics/ui 20.0.1 → 20.0.2

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/LICENSE.md ADDED
@@ -0,0 +1,39 @@
1
+ Copyright (c) 2026 Lotics Technology Company Limited. All rights reserved.
2
+
3
+ This software is proprietary. It is published to the public npm registry for one
4
+ purpose: building on the Lotics platform.
5
+
6
+ In this license, **Lotics** means Lotics Technology Company Limited, and **the
7
+ Lotics platform** means the hosted services Lotics operates at lotics.ai and its
8
+ subdomains, together with applications, packages and integrations that run on or
9
+ communicate with those services.
10
+
11
+ ## Permitted
12
+
13
+ You may install this package and use it **solely in connection with the Lotics
14
+ platform** — including bundling it, in whole or in part, into an application that
15
+ runs on or integrates with the Lotics platform.
16
+
17
+ ## Not permitted
18
+
19
+ Without prior written permission from Lotics you may not:
20
+
21
+ 1. use this software, or any portion of it, independently of the Lotics platform
22
+ — including in any application, product or service that neither runs on nor
23
+ integrates with the Lotics platform;
24
+ 2. copy, republish, redistribute or sublicense the source code, in whole or in
25
+ part, other than as the bundled dependency described above;
26
+ 3. create derivative works from the source code, or incorporate any portion of it
27
+ into another library, product or package;
28
+ 4. reverse engineer, decompile or disassemble any compiled artifact, except to the
29
+ extent that restriction is unenforceable under applicable law;
30
+ 5. use the source code, or any portion of it, to train or fine-tune a machine
31
+ learning model.
32
+
33
+ ## No warranty
34
+
35
+ This software is provided "as is", without warranty of any kind, express or
36
+ implied. In no event shall Lotics be liable for any claim, damages or other
37
+ liability arising from the software or its use.
38
+
39
+ For any other use, contact minh@lotics.ai.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "20.0.1",
3
+ "version": "20.0.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -248,7 +248,9 @@
248
248
  "examples",
249
249
  "AGENTS.md",
250
250
  "MIGRATION.md",
251
- "docs"
251
+ "docs",
252
+ "!src/**/*.test.ts",
253
+ "!src/**/*.test.tsx"
252
254
  ],
253
255
  "publishConfig": {
254
256
  "access": "public"
@@ -258,7 +260,7 @@
258
260
  "url": "https://github.com/lotics/lotics.git",
259
261
  "directory": "packages/ui"
260
262
  },
261
- "license": "MIT",
263
+ "license": "SEE LICENSE IN LICENSE.md",
262
264
  "dependencies": {
263
265
  "@lotics/docx": "^0.1.0",
264
266
  "@lotics/xlsx": "^0.1.0",
@@ -1,96 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { toSegments, anyRunning, lastRunningStep, type AgentUIPart, type AgentSegment, type AgentStep } from "./agent_transform";
3
-
4
- // RN-free logic test for the parts → timeline fold. The renderer (agent_run.tsx)
5
- // is verified visually in the gallery; the mapping — ai's 7-state tool machine to
6
- // the feed's 4 states, plus grouping — is unit-tested here. Fixtures are the ai-sdk
7
- // `dynamic-tool` part shape (contextually typed as `AgentUIPart`, so each concrete
8
- // `state` discriminates to the right member — no casts).
9
-
10
- function groups(parts: AgentUIPart[]): Extract<AgentSegment, { kind: "group" }>[] {
11
- return toSegments(parts).filter((s): s is Extract<AgentSegment, { kind: "group" }> => s.kind === "group");
12
- }
13
-
14
- function onlyStep(parts: AgentUIPart[]): AgentStep {
15
- const [group, ...rest] = groups(parts);
16
- expect(rest).toHaveLength(0);
17
- expect(group.steps).toHaveLength(1);
18
- return group.steps[0];
19
- }
20
-
21
- describe("agent_transform — tool state mapping", () => {
22
- it("input-streaming → running", () => {
23
- expect(onlyStep([{ type: "dynamic-tool", toolName: "update_records", toolCallId: "t", state: "input-streaming", input: {} }]).status).toBe("running");
24
- });
25
-
26
- it("input-available → running", () => {
27
- expect(onlyStep([{ type: "dynamic-tool", toolName: "update_records", toolCallId: "t", state: "input-available", input: {} }]).status).toBe("running");
28
- });
29
-
30
- it("approval-requested → awaiting (parked on a human decision)", () => {
31
- const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-requested", input: { table: "Containers" }, approval: { id: "a1" } }]);
32
- expect(step.status).toBe("awaiting");
33
- // The input still rides along for the on-demand peek.
34
- expect(step.input).toEqual({ table: "Containers" });
35
- });
36
-
37
- it("approval-responded → running (the decision is in, work resumes)", () => {
38
- expect(onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-responded", input: {}, approval: { id: "a1", approved: true } }]).status).toBe("running");
39
- });
40
-
41
- it("output-available → done, carrying the output", () => {
42
- const step = onlyStep([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-available", input: { q: 1 }, output: { rows: 3 } }]);
43
- expect(step.status).toBe("done");
44
- expect(step.output).toEqual({ rows: 3 });
45
- });
46
-
47
- it("output-error → error, carrying errorText", () => {
48
- const step = onlyStep([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-error", input: {}, errorText: "Unknown field" }]);
49
- expect(step.status).toBe("error");
50
- expect(step.errorText).toBe("Unknown field");
51
- });
52
-
53
- it("output-denied → error, with the denial reason as the error text", () => {
54
- const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "output-denied", input: {}, approval: { id: "a1", approved: false, reason: "Delete not permitted" } }]);
55
- expect(step.status).toBe("error");
56
- expect(step.errorText).toBe("Delete not permitted");
57
- });
58
-
59
- it("output-denied without a reason → error with no error text", () => {
60
- const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "output-denied", input: {}, approval: { id: "a1", approved: false } }]);
61
- expect(step.status).toBe("error");
62
- expect(step.errorText).toBeUndefined();
63
- });
64
- });
65
-
66
- describe("agent_transform — streaming signals", () => {
67
- it("anyRunning is true while a call runs, false once every call has settled", () => {
68
- const running = toSegments([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "input-available", input: {} }]);
69
- expect(anyRunning(running)).toBe(true);
70
-
71
- const settled = toSegments([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-available", input: {}, output: {} }]);
72
- expect(anyRunning(settled)).toBe(false);
73
- });
74
-
75
- it("an awaiting call is NOT running — it's parked, so the feed never pulses it", () => {
76
- const parked = toSegments([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-requested", input: {}, approval: { id: "a1" } }]);
77
- expect(anyRunning(parked)).toBe(false);
78
- expect(lastRunningStep(parked)).toBeUndefined();
79
- });
80
- });
81
-
82
- describe("agent_transform — grouping", () => {
83
- it("folds consecutive tool parts into ONE group, split by prose", () => {
84
- const parts: AgentUIPart[] = [
85
- { type: "text", text: "First" },
86
- { type: "dynamic-tool", toolName: "query_records", toolCallId: "a", state: "output-available", input: {}, output: {} },
87
- { type: "dynamic-tool", toolName: "query_records", toolCallId: "b", state: "output-available", input: {}, output: {} },
88
- { type: "text", text: "Then" },
89
- { type: "dynamic-tool", toolName: "delete_records", toolCallId: "c", state: "approval-requested", input: {}, approval: { id: "a1" } },
90
- ];
91
- expect(toSegments(parts).map((s) => s.kind)).toEqual(["text", "group", "text", "group"]);
92
- const [first, second] = groups(parts);
93
- expect(first.steps.map((s) => s.id)).toEqual(["a", "b"]);
94
- expect(second.steps.map((s) => s.status)).toEqual(["awaiting"]);
95
- });
96
- });
@@ -1,103 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { layoutDayColumns, packEventLanes } from "./layout";
3
- import type { CalendarEvent } from "./types";
4
-
5
- const DAY = new Date(2026, 0, 15);
6
- const at = (h: number, m = 0) => new Date(2026, 0, 15, h, m);
7
- const ev = (id: string, sh: number, sm: number, eh: number, em: number): CalendarEvent => ({
8
- id,
9
- title: id,
10
- start: at(sh, sm),
11
- end: at(eh, em),
12
- });
13
-
14
- /** column/columns keyed by event id, for order-independent assertions. */
15
- function geom(events: CalendarEvent[]) {
16
- return Object.fromEntries(
17
- layoutDayColumns(DAY, events).map((c) => [c.event.id, { column: c.column, columns: c.columns }]),
18
- );
19
- }
20
-
21
- describe("layoutDayColumns", () => {
22
- it("returns nothing for an empty day", () => {
23
- expect(layoutDayColumns(DAY, [])).toEqual([]);
24
- });
25
-
26
- it("stacks non-overlapping events in a single column", () => {
27
- const g = geom([ev("a", 9, 0, 10, 0), ev("b", 11, 0, 12, 0)]);
28
- expect(g.a).toEqual({ column: 0, columns: 1 });
29
- expect(g.b).toEqual({ column: 0, columns: 1 });
30
- });
31
-
32
- it("splits two fully overlapping events into two columns", () => {
33
- const g = geom([ev("a", 9, 0, 10, 0), ev("b", 9, 0, 10, 0)]);
34
- expect(g.a.columns).toBe(2);
35
- expect(g.b.columns).toBe(2);
36
- expect(new Set([g.a.column, g.b.column])).toEqual(new Set([0, 1]));
37
- });
38
-
39
- it("gives three concurrent events three columns", () => {
40
- const g = geom([ev("a", 9, 0, 12, 0), ev("b", 9, 0, 12, 0), ev("c", 9, 0, 12, 0)]);
41
- expect(new Set([g.a.column, g.b.column, g.c.column])).toEqual(new Set([0, 1, 2]));
42
- for (const k of ["a", "b", "c"]) expect(g[k].columns).toBe(3);
43
- });
44
-
45
- it("reuses a freed column within a transitive cluster (A→B→C chain)", () => {
46
- // A 9-10, B 9:30-11 (overlaps A), C 10:30-12 (overlaps B, not A).
47
- // One cluster via B; 2 columns; C reuses A's column once A ends.
48
- const g = geom([ev("a", 9, 0, 10, 0), ev("b", 9, 30, 11, 0), ev("c", 10, 30, 12, 0)]);
49
- expect(g.a.columns).toBe(2);
50
- expect(g.a.column).toBe(0);
51
- expect(g.b.column).toBe(1);
52
- expect(g.c.column).toBe(0); // freed by A
53
- });
54
-
55
- it("treats time-separated events as independent clusters", () => {
56
- const g = geom([ev("morn1", 9, 0, 10, 0), ev("morn2", 9, 0, 10, 0), ev("aft", 14, 0, 15, 0)]);
57
- expect(g.morn1.columns).toBe(2);
58
- expect(g.morn2.columns).toBe(2);
59
- expect(g.aft).toEqual({ column: 0, columns: 1 }); // own cluster
60
- });
61
-
62
- it("positions and floors height; clamps to the day window", () => {
63
- const [c] = layoutDayColumns(DAY, [ev("x", 9, 0, 9, 5)]);
64
- expect(c.topMinutes).toBe(540); // 9:00
65
- expect(c.heightMinutes).toBeGreaterThanOrEqual(20); // 5-min event floored
66
- const spill = layoutDayColumns(DAY, [
67
- { id: "y", title: "y", start: at(23, 0), end: new Date(2026, 0, 16, 2, 0) },
68
- ])[0];
69
- expect(spill.topMinutes).toBe(1380); // 23:00
70
- expect(spill.topMinutes + spill.heightMinutes).toBeLessThanOrEqual(1440); // clamped
71
- });
72
- });
73
-
74
- describe("packEventLanes", () => {
75
- const WEEK = new Date(2026, 0, 12); // Mon
76
- const allDay = (id: string, startDay: number, endDay: number): CalendarEvent => ({
77
- id,
78
- title: id,
79
- start: new Date(2026, 0, 12 + startDay),
80
- end: new Date(2026, 0, 12 + endDay),
81
- allDay: true,
82
- });
83
-
84
- it("places non-overlapping spans in one lane", () => {
85
- const { bars, lanes } = packEventLanes([allDay("a", 0, 1), allDay("b", 3, 4)], WEEK, 7);
86
- expect(lanes).toBe(1);
87
- expect(bars.find((b) => b.event.id === "a")).toMatchObject({ startCol: 0, span: 2, lane: 0 });
88
- expect(bars.find((b) => b.event.id === "b")).toMatchObject({ startCol: 3, span: 2, lane: 0 });
89
- });
90
-
91
- it("stacks overlapping multi-day spans into separate lanes", () => {
92
- const { lanes } = packEventLanes([allDay("a", 0, 3), allDay("b", 2, 5)], WEEK, 7);
93
- expect(lanes).toBe(2);
94
- });
95
-
96
- it("clamps a span that starts before the window and filters out-of-range", () => {
97
- const before: CalendarEvent = { id: "x", title: "x", start: new Date(2026, 0, 9), end: new Date(2026, 0, 13), allDay: true };
98
- const after = allDay("y", 9, 10); // entirely after a 7-day window
99
- const { bars } = packEventLanes([before, after], WEEK, 7);
100
- expect(bars).toHaveLength(1);
101
- expect(bars[0]).toMatchObject({ event: { id: "x" }, startCol: 0, span: 2 }); // clamped to days 0..1
102
- });
103
- });
@@ -1,45 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { isColorName, asColorName } from "./colors";
3
-
4
- describe("isColorName", () => {
5
- it("accepts palette family names", () => {
6
- expect(isColorName("blue")).toBe(true);
7
- expect(isColorName("emerald")).toBe(true);
8
- expect(isColorName("zinc")).toBe(true);
9
- });
10
-
11
- it("rejects role keys and scalar colors (not selectable families)", () => {
12
- expect(isColorName("border")).toBe(false);
13
- expect(isColorName("border_shadow")).toBe(false);
14
- expect(isColorName("background")).toBe(false);
15
- expect(isColorName("shadow")).toBe(false);
16
- expect(isColorName("black")).toBe(false);
17
- expect(isColorName("white")).toBe(false);
18
- });
19
-
20
- it("rejects unknown tokens and non-strings", () => {
21
- expect(isColorName("chartreuse")).toBe(false);
22
- expect(isColorName("")).toBe(false);
23
- expect(isColorName(undefined)).toBe(false);
24
- expect(isColorName(null)).toBe(false);
25
- expect(isColorName(42)).toBe(false);
26
- });
27
- });
28
-
29
- describe("asColorName", () => {
30
- it("passes a valid family through", () => {
31
- expect(asColorName("purple")).toBe("purple");
32
- });
33
-
34
- it("degrades an unknown/absent token to the neutral default", () => {
35
- // The graceful-degradation contract: a select option whose color token this
36
- // build doesn't recognize, or none at all, renders neutral rather than break.
37
- expect(asColorName("chartreuse")).toBe("zinc");
38
- expect(asColorName(undefined)).toBe("zinc");
39
- expect(asColorName(null)).toBe("zinc");
40
- });
41
-
42
- it("honors a custom fallback", () => {
43
- expect(asColorName(undefined, "slate")).toBe("slate");
44
- });
45
- });
@@ -1,62 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { getPresetValue, PRESET_IDS, type PresetId } from "./date_filter_presets";
3
-
4
- // Wednesday, 10 June 2026, 14:30 — a fixed clock so every range is deterministic.
5
- const NOW = new Date(2026, 5, 10, 14, 30, 0, 0);
6
-
7
- function ymd(d: Date | null): string {
8
- if (!d) return "null";
9
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
10
- }
11
-
12
- describe("getPresetValue", () => {
13
- it("today spans the start to end of the current day", () => {
14
- const v = getPresetValue("today", NOW)!;
15
- expect(ymd(v.start.date)).toBe("2026-06-10");
16
- expect(ymd(v.end.date)).toBe("2026-06-10");
17
- expect(v.start.date!.getHours()).toBe(0);
18
- expect(v.start.date!.getMilliseconds()).toBe(0);
19
- expect(v.end.date!.getHours()).toBe(23);
20
- expect(v.end.date!.getMinutes()).toBe(59);
21
- expect(v.end.date!.getMilliseconds()).toBe(999);
22
- });
23
-
24
- it("yesterday and tomorrow shift by one day", () => {
25
- expect(ymd(getPresetValue("yesterday", NOW)!.start.date)).toBe("2026-06-09");
26
- expect(ymd(getPresetValue("yesterday", NOW)!.end.date)).toBe("2026-06-09");
27
- expect(ymd(getPresetValue("tomorrow", NOW)!.start.date)).toBe("2026-06-11");
28
- expect(ymd(getPresetValue("tomorrow", NOW)!.end.date)).toBe("2026-06-11");
29
- });
30
-
31
- it("this_week runs Monday → Sunday around now", () => {
32
- const v = getPresetValue("this_week", NOW)!;
33
- expect(ymd(v.start.date)).toBe("2026-06-08"); // Monday
34
- expect(v.start.date!.getDay()).toBe(1);
35
- expect(ymd(v.end.date)).toBe("2026-06-14"); // Sunday
36
- expect(v.end.date!.getDay()).toBe(0);
37
- });
38
-
39
- it("this_month covers the whole calendar month", () => {
40
- const v = getPresetValue("this_month", NOW)!;
41
- expect(ymd(v.start.date)).toBe("2026-06-01");
42
- expect(ymd(v.end.date)).toBe("2026-06-30");
43
- });
44
-
45
- it("last_month covers the previous calendar month", () => {
46
- const v = getPresetValue("last_month", NOW)!;
47
- expect(ymd(v.start.date)).toBe("2026-05-01");
48
- expect(ymd(v.end.date)).toBe("2026-05-31");
49
- });
50
-
51
- it("custom carries no range", () => {
52
- expect(getPresetValue("custom", NOW)).toBeNull();
53
- });
54
-
55
- it("every preset id resolves (only custom is null)", () => {
56
- for (const id of PRESET_IDS) {
57
- const v = getPresetValue(id as PresetId, NOW);
58
- if (id === "custom") expect(v).toBeNull();
59
- else expect(v).not.toBeNull();
60
- }
61
- });
62
- });
@@ -1,204 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- parsePart,
4
- partsToIso,
5
- parseText,
6
- resolveDateCommit,
7
- timeText,
8
- splitTime,
9
- isoToDate,
10
- parseTimeString,
11
- withCalendarDate,
12
- withTimeOfDay,
13
- nowIso,
14
- } from "./date_picker_value";
15
-
16
- describe("parsePart", () => {
17
- it("parses a plain date", () => {
18
- expect(parsePart("2026-05-17")).toEqual({ y: 2026, mo: 5, d: 17, h: 0, mi: 0 });
19
- });
20
-
21
- it("parses a datetime with T or space separator", () => {
22
- expect(parsePart("2026-05-17T14:30")).toEqual({ y: 2026, mo: 5, d: 17, h: 14, mi: 30 });
23
- expect(parsePart("2026-05-17 14:30")).toEqual({ y: 2026, mo: 5, d: 17, h: 14, mi: 30 });
24
- });
25
-
26
- it("is lenient on missing leading zeros", () => {
27
- expect(parsePart("2026-3-7")).toEqual({ y: 2026, mo: 3, d: 7, h: 0, mi: 0 });
28
- });
29
-
30
- it("trims surrounding whitespace", () => {
31
- expect(parsePart(" 2026-05-17 ")).toEqual({ y: 2026, mo: 5, d: 17, h: 0, mi: 0 });
32
- });
33
-
34
- it("rejects impossible calendar dates", () => {
35
- expect(parsePart("2026-02-30")).toBeNull();
36
- expect(parsePart("2026-13-01")).toBeNull();
37
- expect(parsePart("2026-00-10")).toBeNull();
38
- });
39
-
40
- it("rejects out-of-range times", () => {
41
- expect(parsePart("2026-05-17 24:00")).toBeNull();
42
- expect(parsePart("2026-05-17 12:60")).toBeNull();
43
- });
44
-
45
- it("rejects malformed input", () => {
46
- expect(parsePart("not-a-date")).toBeNull();
47
- expect(parsePart("2026/05/17")).toBeNull();
48
- expect(parsePart("")).toBeNull();
49
- });
50
- });
51
-
52
- describe("partsToIso", () => {
53
- it("formats a date without time", () => {
54
- expect(partsToIso({ y: 2026, mo: 5, d: 7, h: 0, mi: 0 }, false)).toBe("2026-05-07");
55
- });
56
-
57
- it("formats a datetime with zero-padded time", () => {
58
- expect(partsToIso({ y: 2026, mo: 5, d: 7, h: 9, mi: 5 }, true)).toBe("2026-05-07T09:05");
59
- });
60
- });
61
-
62
- describe("parseText", () => {
63
- it("returns empty string for blank input (clear)", () => {
64
- expect(parseText("", false)).toBe("");
65
- expect(parseText(" ", false)).toBe("");
66
- });
67
-
68
- it("parses a single date", () => {
69
- expect(parseText("2026-3-7", false)).toBe("2026-03-07");
70
- });
71
-
72
- it("drops the time component for date-only formats", () => {
73
- expect(parseText("2026-05-17 14:30", false)).toBe("2026-05-17");
74
- });
75
-
76
- it("keeps the time component for datetime formats", () => {
77
- expect(parseText("2026-05-17 14:30", true)).toBe("2026-05-17T14:30");
78
- });
79
-
80
- it("returns null for an unparseable value", () => {
81
- expect(parseText("2026-02-30", false)).toBeNull();
82
- expect(parseText("garbage", false)).toBeNull();
83
- });
84
- });
85
-
86
- describe("timeText", () => {
87
- it("extracts HH:mm from a datetime value", () => {
88
- expect(timeText("2026-05-17T09:05")).toBe("09:05");
89
- });
90
-
91
- it("treats a date-only value as midnight", () => {
92
- expect(timeText("2026-05-17")).toBe("00:00");
93
- });
94
-
95
- it("returns empty for an unparseable value", () => {
96
- expect(timeText("")).toBe("");
97
- });
98
- });
99
-
100
- describe("splitTime", () => {
101
- it("splits a canonical time string", () => {
102
- expect(splitTime("14:30")).toEqual({ h: 14, mi: 30 });
103
- });
104
-
105
- it("returns midnight for empty input", () => {
106
- expect(splitTime("")).toEqual({ h: 0, mi: 0 });
107
- });
108
- });
109
-
110
- describe("isoToDate", () => {
111
- it("builds a local Date from an ISO string", () => {
112
- const date = isoToDate("2026-05-17T14:30");
113
- expect(date?.getFullYear()).toBe(2026);
114
- expect(date?.getMonth()).toBe(4);
115
- expect(date?.getDate()).toBe(17);
116
- expect(date?.getHours()).toBe(14);
117
- expect(date?.getMinutes()).toBe(30);
118
- });
119
-
120
- it("returns null for an unparseable string", () => {
121
- expect(isoToDate("")).toBeNull();
122
- expect(isoToDate("2026-02-30")).toBeNull();
123
- });
124
- });
125
-
126
- describe("withCalendarDate", () => {
127
- const may20 = new Date(2026, 4, 20);
128
-
129
- it("keeps the time carried by the previous value for datetime formats", () => {
130
- expect(withCalendarDate(may20, "2026-01-01T14:30", true)).toBe("2026-05-20T14:30");
131
- });
132
-
133
- it("defaults to midnight when the previous value has no time", () => {
134
- expect(withCalendarDate(may20, "", true)).toBe("2026-05-20T00:00");
135
- });
136
-
137
- it("drops the time component for date-only formats", () => {
138
- expect(withCalendarDate(may20, "2026-01-01T14:30", false)).toBe("2026-05-20");
139
- });
140
- });
141
-
142
- describe("withTimeOfDay", () => {
143
- it("keeps the date and applies the new time", () => {
144
- expect(withTimeOfDay("2026-05-20", "09:05")).toBe("2026-05-20T09:05");
145
- expect(withTimeOfDay("2026-05-20T14:30", "08:00")).toBe("2026-05-20T08:00");
146
- });
147
- });
148
-
149
- describe("nowIso", () => {
150
- it("produces a canonical date or datetime string", () => {
151
- expect(nowIso(false)).toMatch(/^\d{4}-\d{2}-\d{2}$/);
152
- expect(nowIso(true)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
153
- });
154
- });
155
-
156
- describe("parseTimeString", () => {
157
- it("parses and zero-pads a valid time", () => {
158
- expect(parseTimeString("9:05")).toBe("09:05");
159
- expect(parseTimeString("14:30")).toBe("14:30");
160
- });
161
-
162
- it("rejects out-of-range or malformed times", () => {
163
- expect(parseTimeString("24:00")).toBeNull();
164
- expect(parseTimeString("12:60")).toBeNull();
165
- expect(parseTimeString("1430")).toBeNull();
166
- expect(parseTimeString("")).toBeNull();
167
- });
168
- });
169
-
170
- describe("resolveDateCommit — the inline editor's commit contract", () => {
171
- it("a typed complete date commits the value", () => {
172
- expect(
173
- resolveDateCommit({ draft: "2026-03-15", value: "2026-01-01", incomplete: false, clearable: false }),
174
- ).toEqual({ kind: "save", value: "2026-03-15" });
175
- // …including onto an empty field.
176
- expect(
177
- resolveDateCommit({ draft: "2026-03-15", value: null, incomplete: false, clearable: false }),
178
- ).toEqual({ kind: "save", value: "2026-03-15" });
179
- });
180
-
181
- it("a partial entry never commits and never clears the stored value", () => {
182
- expect(
183
- resolveDateCommit({ draft: "2026-01-01", value: "2026-01-01", incomplete: true, clearable: true }),
184
- ).toEqual({ kind: "invalid" });
185
- });
186
-
187
- it("an unchanged draft exits without a write (a touched-but-equal date is not a diff)", () => {
188
- expect(
189
- resolveDateCommit({ draft: "2026-01-01", value: "2026-01-01", incomplete: false, clearable: true }),
190
- ).toEqual({ kind: "none" });
191
- expect(resolveDateCommit({ draft: "", value: null, incomplete: false, clearable: true })).toEqual({
192
- kind: "none",
193
- });
194
- });
195
-
196
- it("an emptied entry clears only when the field is clearable", () => {
197
- expect(
198
- resolveDateCommit({ draft: "", value: "2026-01-01", incomplete: false, clearable: true }),
199
- ).toEqual({ kind: "clear" });
200
- expect(
201
- resolveDateCommit({ draft: "", value: "2026-01-01", incomplete: false, clearable: false }),
202
- ).toEqual({ kind: "none" });
203
- });
204
- });
@@ -1,60 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { nextRangeSelection, type DateRangeSelection } from "./date_range_selection";
3
-
4
- const d = (day: number): Date => new Date(2026, 6, day);
5
- const ymd = (x: Date | null): string =>
6
- x ? `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}` : "null";
7
- const EMPTY: DateRangeSelection = { start: null, end: null };
8
-
9
- describe("nextRangeSelection", () => {
10
- it("opens a range on the first click", () => {
11
- const next = nextRangeSelection(EMPTY, d(14));
12
- expect(ymd(next.start)).toBe("2026-07-14");
13
- expect(next.end).toBeNull();
14
- });
15
-
16
- it("closes the open range on the second click", () => {
17
- const next = nextRangeSelection({ start: d(14), end: null }, d(20));
18
- expect(ymd(next.start)).toBe("2026-07-14");
19
- expect(ymd(next.end)).toBe("2026-07-20");
20
- });
21
-
22
- it("orders the bounds when the later day is clicked first", () => {
23
- const next = nextRangeSelection({ start: d(20), end: null }, d(14));
24
- expect(ymd(next.start)).toBe("2026-07-14");
25
- expect(ymd(next.end)).toBe("2026-07-20");
26
- });
27
-
28
- // The whole point of dropping the single/range mode: a range must be
29
- // reachable from EVERY starting value, including one that is already a
30
- // single day. Under the mode, a single-day value silently disabled range
31
- // selection entirely — two clicks just moved the one day around.
32
- it("reaches a range from a single-day value", () => {
33
- const singleDay: DateRangeSelection = { start: d(31), end: d(31) };
34
- const opened = nextRangeSelection(singleDay, d(1));
35
- const closed = nextRangeSelection(opened, d(20));
36
- expect(ymd(closed.start)).toBe("2026-07-01");
37
- expect(ymd(closed.end)).toBe("2026-07-20");
38
- });
39
-
40
- it("reaches a range from a closed multi-day range", () => {
41
- const opened = nextRangeSelection({ start: d(1), end: d(5) }, d(10));
42
- expect(ymd(opened.start)).toBe("2026-07-10");
43
- expect(opened.end).toBeNull();
44
- expect(ymd(nextRangeSelection(opened, d(12)).end)).toBe("2026-07-12");
45
- });
46
-
47
- // Selecting one day is clicking it twice — the replacement for the mode.
48
- it("selects a single day when the same day is clicked twice", () => {
49
- const next = nextRangeSelection(nextRangeSelection(EMPTY, d(14)), d(14));
50
- expect(ymd(next.start)).toBe("2026-07-14");
51
- expect(ymd(next.end)).toBe("2026-07-14");
52
- });
53
-
54
- it("never mutates the selection it is given", () => {
55
- const current: DateRangeSelection = { start: d(14), end: null };
56
- nextRangeSelection(current, d(20));
57
- expect(ymd(current.start)).toBe("2026-07-14");
58
- expect(current.end).toBeNull();
59
- });
60
- });