@lotics/ui 1.15.0 → 1.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -9,6 +9,11 @@
9
9
  "./download": "./src/download.ts",
10
10
  "./file_badge": "./src/file_badge.tsx",
11
11
  "./file_thumbnail": "./src/file_thumbnail.tsx",
12
+ "./file_preview": {
13
+ "react-native": "./src/file_preview.tsx",
14
+ "default": "./src/file_preview.web.tsx"
15
+ },
16
+ "./file_preview_types": "./src/file_preview_types.ts",
12
17
  "./file_gallery_modal": "./src/file_gallery_modal.tsx",
13
18
  "./pagination": "./src/pagination.tsx",
14
19
  "./bar_chart": "./src/bar_chart.tsx",
@@ -99,6 +104,7 @@
99
104
  "./page_header": "./src/page_header.tsx",
100
105
  "./pager_view": "./src/pager_view.tsx",
101
106
  "./date_picker": "./src/date_picker.tsx",
107
+ "./date_filter": "./src/date_filter.tsx",
102
108
  "./time_picker": "./src/time_picker.tsx",
103
109
  "./time_field": "./src/time_field.tsx",
104
110
  "./date_calendar": "./src/date_calendar.tsx",
@@ -153,6 +159,8 @@
153
159
  },
154
160
  "license": "MIT",
155
161
  "peerDependencies": {
162
+ "@lotics/docx": "^0.1.0",
163
+ "@lotics/xlsx": "^0.1.0",
156
164
  "@react-native-picker/picker": ">=2.0.0",
157
165
  "expo-image": ">=3.0.0",
158
166
  "lucide-react": ">=0.460.0",
@@ -165,6 +173,12 @@
165
173
  "recharts": ">=3.0.0"
166
174
  },
167
175
  "peerDependenciesMeta": {
176
+ "@lotics/docx": {
177
+ "optional": true
178
+ },
179
+ "@lotics/xlsx": {
180
+ "optional": true
181
+ },
168
182
  "expo-image": {
169
183
  "optional": true
170
184
  },
@@ -187,6 +201,8 @@
187
201
  "test": "vitest run"
188
202
  },
189
203
  "devDependencies": {
204
+ "@lotics/docx": "^0.1.0",
205
+ "@lotics/xlsx": "^0.1.0",
190
206
  "recharts": "^3.8.1"
191
207
  }
192
208
  }
package/src/combobox.tsx CHANGED
@@ -29,6 +29,13 @@ export interface ComboboxProps<T extends string = string> {
29
29
  searchPlaceholder?: string;
30
30
  /** Shown when there are no results and not loading. Default: "No results". */
31
31
  emptyText?: string;
32
+ /** Accept free entry: when the typed query matches no option, offer it as a
33
+ * custom value. `onValueChange` then receives `{ value: query, label: query }`.
34
+ * The consumer decides what an unknown value means (e.g. a manually-typed id). */
35
+ allowCustom?: boolean;
36
+ /** Label for the free-entry row (default: the raw query). Return `null` to
37
+ * suppress it for a given query (e.g. still incomplete/invalid). */
38
+ customOptionLabel?: (query: string) => string | null;
32
39
  disabled?: boolean;
33
40
  autoFocus?: boolean;
34
41
  testID?: string;
@@ -56,6 +63,8 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
56
63
  placeholder,
57
64
  searchPlaceholder,
58
65
  emptyText,
66
+ allowCustom,
67
+ customOptionLabel,
59
68
  disabled = false,
60
69
  autoFocus = false,
61
70
  testID,
@@ -73,9 +82,10 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
73
82
  (v: T) => {
74
83
  const opt = options.find((o) => o.value === v);
75
84
  if (opt) onValueChange(opt);
85
+ else if (allowCustom && v) onValueChange({ value: v, label: v });
76
86
  setOpen(false);
77
87
  },
78
- [options, onValueChange],
88
+ [options, onValueChange, allowCustom],
79
89
  );
80
90
 
81
91
  return (
@@ -117,6 +127,8 @@ export function Combobox<T extends string>(props: ComboboxProps<T>) {
117
127
  onValueChange={handleSelect}
118
128
  onRequestClose={() => setOpen(false)}
119
129
  enableSearch
130
+ allowCustom={allowCustom}
131
+ customOptionLabel={customOptionLabel}
120
132
  onSearchChange={debouncedSearch}
121
133
  // Local filtering is wrong when the consumer drives search server-side.
122
134
  serverFiltered={onSearchChange !== undefined}
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { customOptionFor } from "./custom_option";
3
+
4
+ const opts = [
5
+ { value: "BX1N", label: "BX1N" },
6
+ { value: "UX1N", label: "UX1N" },
7
+ ];
8
+
9
+ describe("customOptionFor", () => {
10
+ it("offers the trimmed query when nothing matches", () => {
11
+ expect(customOptionFor({ allowCustom: true, multi: false, query: " ABCD ", options: opts }))
12
+ .toEqual({ value: "ABCD", label: "ABCD" });
13
+ });
14
+
15
+ it("suppressed when free entry is off", () => {
16
+ expect(customOptionFor({ allowCustom: false, multi: false, query: "ABCD", options: opts })).toBeNull();
17
+ expect(customOptionFor({ allowCustom: undefined, multi: false, query: "ABCD", options: opts })).toBeNull();
18
+ });
19
+
20
+ it("suppressed for multi-select", () => {
21
+ expect(customOptionFor({ allowCustom: true, multi: true, query: "ABCD", options: opts })).toBeNull();
22
+ });
23
+
24
+ it("suppressed for an empty / whitespace query", () => {
25
+ expect(customOptionFor({ allowCustom: true, multi: false, query: " ", options: opts })).toBeNull();
26
+ });
27
+
28
+ it("suppressed when the query matches an option value (case-insensitive)", () => {
29
+ expect(customOptionFor({ allowCustom: true, multi: false, query: "bx1n", options: opts })).toBeNull();
30
+ });
31
+
32
+ it("uses customOptionLabel for the row label but keeps the raw value", () => {
33
+ expect(customOptionFor({
34
+ allowCustom: true, multi: false, query: "ABCD", options: opts,
35
+ customOptionLabel: (q) => `Add "${q}"`,
36
+ })).toEqual({ value: "ABCD", label: 'Add "ABCD"' });
37
+ });
38
+
39
+ it("suppressed when customOptionLabel returns null", () => {
40
+ expect(customOptionFor({
41
+ allowCustom: true, multi: false, query: "AB", options: opts,
42
+ customOptionLabel: () => null,
43
+ })).toBeNull();
44
+ });
45
+
46
+ it("ignores undefined / false holes in the options list", () => {
47
+ expect(customOptionFor({ allowCustom: true, multi: false, query: "ABCD", options: [undefined, false, ...opts] }))
48
+ .toEqual({ value: "ABCD", label: "ABCD" });
49
+ });
50
+ });
@@ -0,0 +1,30 @@
1
+ import type { PickerOption } from "./picker";
2
+
3
+ /**
4
+ * Resolve the free-entry ("custom") option a search picker should offer for the
5
+ * current query — or `null` when it should not be offered. The option's `value`
6
+ * stays the raw trimmed query; consumers decide what an unknown value means.
7
+ *
8
+ * Suppressed when: free entry is disabled, the picker is multi-select, the query
9
+ * is empty, the query already matches an existing option's value
10
+ * (case-insensitive), or `customOptionLabel` returns `null` for it.
11
+ *
12
+ * Pure and RN-free so it is unit-testable in isolation from `PickerMenu`.
13
+ */
14
+ export function customOptionFor<T extends string>(args: {
15
+ allowCustom: boolean | undefined;
16
+ multi: boolean;
17
+ query: string;
18
+ options: (PickerOption<T> | undefined | false)[];
19
+ customOptionLabel?: (query: string) => string | null;
20
+ }): PickerOption<T> | null {
21
+ const { allowCustom, multi, query, options, customOptionLabel } = args;
22
+ if (!allowCustom || multi) return null;
23
+ const q = query.trim();
24
+ if (!q || options.some((o) => o && String(o.value).toLowerCase() === q.toLowerCase())) {
25
+ return null;
26
+ }
27
+ const label = customOptionLabel ? customOptionLabel(q) : q;
28
+ if (label == null) return null;
29
+ return { value: q as T, label };
30
+ }
@@ -0,0 +1,389 @@
1
+ import React, { useCallback, useMemo, useRef, useState } from "react";
2
+ import { ScrollView, StyleSheet, View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { colors } from "./colors";
5
+ import { MenuButton } from "./menu_button";
6
+ import { Calendar, CalendarRangeValue, CalendarRef } from "./date_calendar";
7
+ import { TimeField } from "./time_field";
8
+ import { Switch } from "./switch";
9
+ import { useScreenSize } from "./use_screen_size";
10
+ import { SegmentLabels } from "./date_segments";
11
+ import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
12
+
13
+ type SelectionMode = "single" | "range";
14
+
15
+ // =============================================================================
16
+ // Types
17
+ // =============================================================================
18
+
19
+ export interface DateFilterValue {
20
+ start: { date: Date | null; time: string | null };
21
+ end: { date: Date | null; time: string | null };
22
+ }
23
+
24
+ /**
25
+ * Translated labels. Extends `SegmentLabels` (consumed by the time fields);
26
+ * the rest are the preset names and the range-display strings. The frontend
27
+ * feeds these from `useDateFilterLabels`; standalone apps pass literals.
28
+ */
29
+ export interface DateFilterLabels extends SegmentLabels {
30
+ today: string;
31
+ yesterday: string;
32
+ tomorrow: string;
33
+ thisWeek: string;
34
+ thisMonth: string;
35
+ lastMonth: string;
36
+ custom: string;
37
+ from: string;
38
+ to: string;
39
+ selectDateRange: string;
40
+ selectDate: string;
41
+ }
42
+
43
+ const DEFAULT_LABELS: DateFilterLabels = {
44
+ year: "Year",
45
+ month: "Month",
46
+ day: "Day",
47
+ hour: "Hour",
48
+ minute: "Minute",
49
+ dayPeriod: "AM/PM",
50
+ today: "Today",
51
+ yesterday: "Yesterday",
52
+ tomorrow: "Tomorrow",
53
+ thisWeek: "This week",
54
+ thisMonth: "This month",
55
+ lastMonth: "Last month",
56
+ custom: "Custom",
57
+ from: "From",
58
+ to: "To",
59
+ selectDateRange: "Select date range",
60
+ selectDate: "Select date",
61
+ };
62
+
63
+ export interface DateFilterProps {
64
+ value: DateFilterValue;
65
+ onValueChange: (value: DateFilterValue) => void;
66
+ includeTime?: boolean;
67
+ /** Translated labels. Defaults to English. */
68
+ labels?: Partial<DateFilterLabels>;
69
+ /** BCP-47 locale for the calendar and date display. Defaults to "en-US". */
70
+ locale?: string;
71
+ }
72
+
73
+ // =============================================================================
74
+ // Helpers
75
+ // =============================================================================
76
+
77
+ function presetLabel(id: PresetId, labels: DateFilterLabels): string {
78
+ switch (id) {
79
+ case "today":
80
+ return labels.today;
81
+ case "yesterday":
82
+ return labels.yesterday;
83
+ case "tomorrow":
84
+ return labels.tomorrow;
85
+ case "this_week":
86
+ return labels.thisWeek;
87
+ case "this_month":
88
+ return labels.thisMonth;
89
+ case "last_month":
90
+ return labels.lastMonth;
91
+ case "custom":
92
+ return labels.custom;
93
+ }
94
+ }
95
+
96
+ function formatDateDisplay(date: Date | null, locale: string | undefined): string {
97
+ if (!date) return "";
98
+ const isoDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
99
+ try {
100
+ return new Intl.DateTimeFormat(locale, {
101
+ month: "short",
102
+ day: "numeric",
103
+ year: "numeric",
104
+ }).format(date);
105
+ } catch {
106
+ return isoDate;
107
+ }
108
+ }
109
+
110
+ // =============================================================================
111
+ // Main Component
112
+ // =============================================================================
113
+
114
+ export function DateFilter(props: DateFilterProps) {
115
+ const { value, onValueChange, includeTime = false, locale } = props;
116
+ const labels = useMemo<DateFilterLabels>(
117
+ () => ({ ...DEFAULT_LABELS, ...props.labels }),
118
+ [props.labels],
119
+ );
120
+ const screenSize = useScreenSize();
121
+ const calendarRef = useRef<CalendarRef>(null);
122
+ const [selectionMode, setSelectionMode] = useState<SelectionMode>(() => {
123
+ if (value.start.date && value.end.date) {
124
+ if (value.start.date.toDateString() !== value.end.date.toDateString()) {
125
+ return "range";
126
+ }
127
+ }
128
+ return "single";
129
+ });
130
+
131
+ const calendarValue: CalendarRangeValue = useMemo(
132
+ () => ({ start: value.start.date, end: value.end.date }),
133
+ [value.start.date, value.end.date],
134
+ );
135
+
136
+ const handleCalendarChange = useCallback(
137
+ (newValue: CalendarRangeValue) => {
138
+ // In single mode, one click selects a single date immediately
139
+ if (selectionMode === "single" && newValue.start && !newValue.end) {
140
+ onValueChange({
141
+ start: { date: newValue.start, time: value.start.time },
142
+ end: { date: newValue.start, time: value.end.time },
143
+ });
144
+ return;
145
+ }
146
+ onValueChange({
147
+ start: { date: newValue.start, time: value.start.time },
148
+ end: { date: newValue.end, time: value.end.time },
149
+ });
150
+ },
151
+ [onValueChange, value.start.time, value.end.time, selectionMode],
152
+ );
153
+
154
+ const handleModeChange = useCallback(
155
+ (isRange: boolean) => {
156
+ const newMode: SelectionMode = isRange ? "range" : "single";
157
+ setSelectionMode(newMode);
158
+
159
+ // Switching to single: collapse range to start date
160
+ if (newMode === "single" && value.start.date) {
161
+ onValueChange({
162
+ start: value.start,
163
+ end: { date: value.start.date, time: value.start.time },
164
+ });
165
+ }
166
+ },
167
+ [value, onValueChange],
168
+ );
169
+
170
+ const handleStartTimeChange = useCallback(
171
+ (time: string) => {
172
+ onValueChange({ ...value, start: { ...value.start, time: time || null } });
173
+ },
174
+ [onValueChange, value],
175
+ );
176
+
177
+ const handleEndTimeChange = useCallback(
178
+ (time: string) => {
179
+ onValueChange({ ...value, end: { ...value.end, time: time || null } });
180
+ },
181
+ [onValueChange, value],
182
+ );
183
+
184
+ // Which preset (if any) the current value matches.
185
+ const activePresetId = useMemo((): PresetId | null => {
186
+ if (!value.start.date && !value.end.date) return null;
187
+ const now = new Date();
188
+ for (const id of PRESET_IDS) {
189
+ const presetValue = getPresetValue(id, now);
190
+ if (!presetValue) continue; // "custom"
191
+ if (
192
+ value.start.date?.toDateString() === presetValue.start.date?.toDateString() &&
193
+ value.end.date?.toDateString() === presetValue.end.date?.toDateString()
194
+ ) {
195
+ return id;
196
+ }
197
+ }
198
+ return "custom";
199
+ }, [value]);
200
+
201
+ const handlePresetSelect = useCallback(
202
+ (id: PresetId) => {
203
+ // Re-clicking the active preset clears the filter
204
+ if (activePresetId === id) {
205
+ onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
206
+ return;
207
+ }
208
+
209
+ const presetValue = getPresetValue(id, new Date());
210
+ if (!presetValue) {
211
+ // "Custom" clears the value so the user can select manually
212
+ onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
213
+ return;
214
+ }
215
+
216
+ // Auto-switch to range mode for multi-day presets
217
+ if (presetValue.start.date && presetValue.end.date) {
218
+ const isSingleDay =
219
+ presetValue.start.date.toDateString() === presetValue.end.date.toDateString();
220
+ if (!isSingleDay && selectionMode === "single") setSelectionMode("range");
221
+ }
222
+
223
+ onValueChange(presetValue);
224
+
225
+ if (presetValue.start.date) {
226
+ calendarRef.current?.navigateToMonth(
227
+ presetValue.start.date.getFullYear(),
228
+ presetValue.start.date.getMonth(),
229
+ );
230
+ }
231
+ },
232
+ [onValueChange, activePresetId, selectionMode],
233
+ );
234
+
235
+ const renderPreset = (id: PresetId) => {
236
+ const isActive = activePresetId === id;
237
+ return (
238
+ <MenuButton
239
+ key={id}
240
+ title={
241
+ <Text size="sm" color={isActive ? "inverted" : "default"}>
242
+ {presetLabel(id, labels)}
243
+ </Text>
244
+ }
245
+ onPress={() => handlePresetSelect(id)}
246
+ style={[styles.presetItem, isActive && styles.presetItemActive]}
247
+ />
248
+ );
249
+ };
250
+
251
+ return (
252
+ <View style={[styles.container, { flexDirection: screenSize.small ? "column" : "row" }]}>
253
+ {/* Presets sidebar (desktop only) */}
254
+ {!screenSize.small && (
255
+ <View style={styles.presetsSidebar}>{PRESET_IDS.map(renderPreset)}</View>
256
+ )}
257
+
258
+ {/* Calendar and time pickers */}
259
+ <View style={styles.mainContent}>
260
+ <Calendar<"range">
261
+ ref={calendarRef}
262
+ mode="range"
263
+ value={calendarValue}
264
+ onValueChange={handleCalendarChange}
265
+ locale={locale}
266
+ />
267
+
268
+ {selectionMode === "range" ? (
269
+ <View style={styles.rangeDisplay}>
270
+ {value.start.date ? (
271
+ <View style={styles.rangeItem}>
272
+ <Text size="xs" color="zinc-500">
273
+ {labels.from}
274
+ </Text>
275
+ <Text size="sm" weight="medium">
276
+ {formatDateDisplay(value.start.date, locale) || labels.selectDate}
277
+ </Text>
278
+ {includeTime && (
279
+ <View style={{ marginTop: 4 }}>
280
+ <TimeField
281
+ value={value.start.time || ""}
282
+ onChange={handleStartTimeChange}
283
+ segmentLabels={labels}
284
+ locale={locale}
285
+ />
286
+ </View>
287
+ )}
288
+ </View>
289
+ ) : (
290
+ <View style={styles.rangeItem} />
291
+ )}
292
+ {value.end.date ? (
293
+ <View style={styles.rangeItem}>
294
+ <Text size="xs" color="zinc-500">
295
+ {labels.to}
296
+ </Text>
297
+ <Text size="sm" weight="medium">
298
+ {formatDateDisplay(value.end.date, locale) || labels.selectDate}
299
+ </Text>
300
+ {includeTime && value.end.date && (
301
+ <View style={{ marginTop: 4 }}>
302
+ <TimeField
303
+ value={value.end.time || ""}
304
+ onChange={handleEndTimeChange}
305
+ segmentLabels={labels}
306
+ locale={locale}
307
+ />
308
+ </View>
309
+ )}
310
+ </View>
311
+ ) : (
312
+ <View style={styles.rangeItem} />
313
+ )}
314
+ </View>
315
+ ) : null}
316
+
317
+ <View style={styles.modeToggle}>
318
+ <Switch value={selectionMode === "range"} onChange={handleModeChange} />
319
+ <Text size="sm" color="muted">
320
+ {labels.selectDateRange}
321
+ </Text>
322
+ </View>
323
+ </View>
324
+
325
+ {/* Presets (mobile only - below calendar) */}
326
+ {screenSize.small && (
327
+ <ScrollView
328
+ horizontal
329
+ showsHorizontalScrollIndicator={false}
330
+ style={styles.presetsScrollView}
331
+ contentContainerStyle={styles.presetsScrollContent}
332
+ >
333
+ {PRESET_IDS.map(renderPreset)}
334
+ </ScrollView>
335
+ )}
336
+ </View>
337
+ );
338
+ }
339
+
340
+ // =============================================================================
341
+ // Styles
342
+ // =============================================================================
343
+
344
+ const styles = StyleSheet.create({
345
+ container: {
346
+ backgroundColor: colors.white,
347
+ borderRadius: 8,
348
+ gap: 16,
349
+ },
350
+ presetsSidebar: {
351
+ minWidth: 140,
352
+ },
353
+ presetsScrollView: {
354
+ borderTopWidth: 1,
355
+ borderTopColor: colors.zinc["200"],
356
+ paddingTop: 8,
357
+ },
358
+ presetsScrollContent: {
359
+ flexDirection: "row",
360
+ gap: 4,
361
+ },
362
+ presetItem: {
363
+ marginHorizontal: 0,
364
+ marginBottom: 2,
365
+ },
366
+ presetItemActive: {
367
+ backgroundColor: colors.zinc["800"],
368
+ },
369
+ mainContent: {
370
+ flex: 1,
371
+ },
372
+ modeToggle: {
373
+ flexDirection: "row",
374
+ alignItems: "center",
375
+ gap: 8,
376
+ paddingTop: 8,
377
+ paddingBottom: 8,
378
+ paddingLeft: 8,
379
+ },
380
+ rangeDisplay: {
381
+ flexDirection: "row",
382
+ gap: 16,
383
+ paddingTop: 8,
384
+ },
385
+ rangeItem: {
386
+ flex: 1,
387
+ paddingLeft: 8,
388
+ },
389
+ });
@@ -0,0 +1,62 @@
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
+ });