@lotics/ui 20.0.0 → 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.
@@ -115,6 +115,11 @@ One per type:
115
115
  GROWS with its content; only a plain label row is the fixed-height `MenuButton`. So a short
116
116
  code + the full official name underneath is a supported option shape, not a hack. Reach for it
117
117
  whenever the real name is too long to READ in a list but must stay exact somewhere.
118
+ **The popover is the FIELD's width** (floored at `MIN_CONTROL_WIDTH` so a dense grid cell still
119
+ opens something readable) — shared by `Select` and `Combobox`, every popover anchored to a
120
+ field. So option text longer than the field WRAPS; it never widens the panel, and a paragraph-
121
+ length option cannot open a list wider than the drawer or column the field lives in. Give a
122
+ long description line a `numberOfLines` clamp so rows stay even and the list stays scannable.
118
123
  - **`InlineMemberSelect`** — a `MemberChip` at rest → member picker; the inline twin of
119
124
  `MemberSelect`; worked example: `tpl_record`'s "Sales owner" fact.
120
125
  - **`InlineDatePicker`** — `format="datetime"` for always-on time; `optionalTime` to let the user
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "20.0.0",
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",
@@ -51,7 +51,14 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
51
51
  onPress={() => onValueChange(option.value)}
52
52
  accessibilityRole="button"
53
53
  accessibilityLabel={`${accessibilityLabel}: ${option.label}`}
54
- accessibilityState={{ selected: active }}
54
+ // A TOGGLE BUTTON, so which chip is active is announced. It has to
55
+ // be a raw `aria-pressed`, not `accessibilityState`: react-native-web
56
+ // maps only the states with a native counterpart, and it drops
57
+ // `selected` on a `button` role — which left the active chip
58
+ // distinguished by nothing but a 1px ring, invisible to a screen
59
+ // reader (`RadioPicker` and `SegmentedControl` pass raw `aria-checked`
60
+ // for the same reason).
61
+ aria-pressed={active}
55
62
  // The shared pill-surface contract (height/border/radius/white +
56
63
  // hover/press wash + the selected box-shadow ring); the chip only
57
64
  // adds its horizontal padding.
@@ -5,6 +5,13 @@ import { colors } from "./colors";
5
5
  * buttons) aligns to it so a toolbar row reads as one band. */
6
6
  export const CONTROL_HEIGHT = 40;
7
7
 
8
+ /** The narrowest a control may be and still be usable — below it, editors get
9
+ * crushed and option text has nowhere to go. It is the threshold at which a
10
+ * `DetailTable` gives up its side-by-side columns and stacks, and the floor
11
+ * under a field popover's width, so "too narrow to use" means one thing across
12
+ * the kit rather than a number re-picked per component. */
13
+ export const MIN_CONTROL_WIDTH = 160;
14
+
8
15
  /** The system control radius — every interactive control surface (Button, the
9
16
  * inputs/selects/pickers, SearchInput, MenuButton, Tabs, the SegmentedControl
10
17
  * track, and ChipGroup/FilterChip/Chip via the surfaces below) wears it,
@@ -1,5 +1,6 @@
1
1
  import { createContext, ReactNode, useContext, useState } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
+ import { MIN_CONTROL_WIDTH } from "./control_surface";
3
4
  import { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
4
5
  import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
5
6
  import { getInputLineHeight } from "./text_utils";
@@ -16,10 +17,6 @@ interface DetailTableColumns {
16
17
 
17
18
  const DetailTableContext = createContext<DetailTableColumns | null>(null);
18
19
 
19
- /** The value cell must keep at least this width before the table gives up its
20
- * side-by-side columns and stacks — below it, editors get crushed. */
21
- const MIN_VALUE_WIDTH = 160;
22
-
23
20
  /** THE kit's label column — ~18 characters, where real field names live. It is
24
21
  * this table's default AND a `TaskList`'s, so a record's fields and a task's
25
22
  * read as one language on a surface carrying both; that only holds while the
@@ -73,7 +70,7 @@ export interface DetailTableProps {
73
70
  * the switch is automatic.
74
71
  */
75
72
  export function DetailTable(props: DetailTableProps) {
76
- const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
73
+ const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_CONTROL_WIDTH, children, style } = props;
77
74
  const [width, setWidth] = useState<number | null>(null);
78
75
  const stacked =
79
76
  width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
package/src/popover.tsx CHANGED
@@ -9,6 +9,7 @@ import React, {
9
9
  } from "react";
10
10
  import { ScrollView, View, ViewStyle, StyleProp } from "react-native";
11
11
  import { colors } from "./colors";
12
+ import { MIN_CONTROL_WIDTH } from "./control_surface";
12
13
  import { IconButton } from "./icon_button";
13
14
  import { Portal } from "./portal";
14
15
  import { Divider } from "./divider";
@@ -641,10 +642,27 @@ export function PopoverContent(props: PopoverContentProps) {
641
642
  display: "flex",
642
643
  flexDirection: "column",
643
644
  }),
645
+ // INHERIT means inherit — the width, not just a floor under it. The
646
+ // flag is set only by a popover anchored to a FIELD (Select,
647
+ // InlineSelect, Combobox), and a field's own width is the right width
648
+ // for the list of values that field can hold: option text longer than
649
+ // the field belongs on a second line, not on a wider panel.
650
+ //
651
+ // As a floor alone this LOOKED right, because with short options
652
+ // max-content lands on the trigger width anyway. It broke the moment
653
+ // an option was a sentence: the panel grew to the `maxWidth: 800` cap
654
+ // and then slid sideways to stay on screen, so a 371px field opened an
655
+ // 800px list starting 421px to its left, outside the drawer that owns
656
+ // the field.
657
+ //
658
+ // Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger — a
659
+ // `variant="cell"` select in a dense grid column — still opens a list
660
+ // wide enough to read, rather than inheriting a width nothing fits in.
644
661
  ...(inheritTriggerWidth &&
645
662
  !small &&
646
663
  triggerWidth > 0 && {
647
- minWidth: triggerWidth,
664
+ width: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
665
+ maxWidth: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
648
666
  }),
649
667
  }}
650
668
  onClick={(e) => e.stopPropagation()}
@@ -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
- });