authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-14 17:01:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 17:01:35-07:00
log3be02ae5791480b8c8bf3a13020a932378eca1ba
tree2d160591f6361063659c08c9fa282393daddeb7d
parent014016a62344ab03dbccdb73c3c1861bc4f7e349
signature Signed by PGP key B5690EEEBB952194

feat: add custom snooze dates and durations (#11800)


17 files changed, 1109 insertions(+), 98 deletions(-)

apps/mobile/src/components/SegmentedControl.tsx created+74
...@@ -0,0 +1,74 @@
1import { Platform, Pressable, View } from "react-native";
2import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated";
3import { AppText as Text } from "./AppText";
4import { cn } from "../lib/cn";
5
6export function SegmentedControl<Value extends number | string>(props: {
7 readonly options: readonly {
8 readonly value: Value;
9 readonly label: string;
10 readonly accessibilityLabel?: string;
11 }[];
12 readonly selected: Value;
13 readonly onSelect: (value: Value) => void;
14 /** The tab bar is full height; filters under it are shorter so it stays primary. */
15 readonly size?: "default" | "compact";
16 /** "tab" for the view switcher; filters stay plain buttons. */
17 readonly role?: "tab" | "button";
18 readonly className?: string;
19}) {
20 const compact = props.size === "compact";
21 return (
22 <View
23 accessible={false}
24 className={cn(
25 "flex-row overflow-hidden rounded-full border-continuous bg-card",
26 props.className,
27 )}
28 >
29 <Animated.View
30 pointerEvents="none"
31 layout={LinearTransition.duration(200)
32 .easing(Easing.out(Easing.cubic))
33 .reduceMotion(ReduceMotion.System)}
34 className="absolute bottom-0 top-0 rounded-full bg-subtle-strong"
35 style={{
36 width: `${100 / props.options.length}%`,
37 start: `${
38 (Math.max(
39 0,
40 props.options.findIndex((option) => option.value === props.selected),
41 ) *
42 100) /
43 props.options.length
44 }%`,
45 }}
46 />
47 {props.options.map((option) => {
48 const active = option.value === props.selected;
49 return (
50 <Pressable
51 key={String(option.value)}
52 accessibilityRole={Platform.OS === "ios" ? "button" : (props.role ?? "button")}
53 accessibilityLabel={option.accessibilityLabel ?? option.label}
54 accessibilityState={{ selected: active }}
55 onPress={() => props.onSelect(option.value)}
56 className={cn(
57 "flex-1 items-center justify-center rounded-full",
58 compact ? "h-9" : "h-11",
59 )}
60 >
61 <Text
62 className={cn(
63 compact ? "text-xs" : "text-sm",
64 active ? "font-t3-medium text-foreground" : "text-foreground-muted",
65 )}
66 >
67 {option.label}
68 </Text>
69 </Pressable>
70 );
71 })}
72 </View>
73 );
74}
apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx created+268
...@@ -0,0 +1,268 @@
1import {
2 Button,
3 DatePicker,
4 Host,
5 HStack,
6 Menu,
7 Picker,
8 Popover,
9 Spacer,
10 Text,
11 VStack,
12} from "@expo/ui/swift-ui";
13import {
14 background,
15 buttonStyle,
16 datePickerStyle,
17 font,
18 foregroundStyle,
19 frame,
20 padding,
21 pickerStyle,
22 presentationBackground,
23 shapes,
24 tag,
25} from "@expo/ui/swift-ui/modifiers";
26import {
27 localSnoozeDate,
28 localSnoozeTime,
29 resolveCustomSnooze,
30 type CustomSnoozeInput,
31} from "@t3tools/client-runtime/state/thread-settled";
32import { useState } from "react";
33import { Modal, Pressable, ScrollView, View } from "react-native";
34import { AppText } from "../../components/AppText";
35import { SegmentedControl } from "../../components/SegmentedControl";
36import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
37
38const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);
39const modes = [
40 { value: "date", label: "Date and time" },
41 { value: "duration", label: "Duration" },
42] as const;
43const units = [
44 { value: "minutes", label: "Minutes" },
45 { value: "hours", label: "Hours" },
46 { value: "days", label: "Days" },
47] as const;
48
49export function CustomSnoozeSheet(props: {
50 readonly onClose: () => void;
51 readonly onSnooze: (snoozedUntil: string) => void;
52}) {
53 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
54 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
55 const [amount, setAmount] = useState(2);
56 const [amountOpen, setAmountOpen] = useState(false);
57 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
58 const [error, setError] = useState<string | null>(null);
59 const { themeVariables: colors, themeAppearance, appearance } = useAppearancePreferences();
60 const updateDate = (value: Date) => {
61 setDate(value);
62 setError(null);
63 };
64
65 const submit = () => {
66 const input: CustomSnoozeInput =
67 mode === "date"
68 ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) }
69 : { mode, amount: String(amount), unit };
70 const snoozedUntil = resolveCustomSnooze(input, new Date());
71 if (!snoozedUntil) {
72 setError(
73 mode === "date" ? "Choose a date and time in the future." : "Enter a positive duration.",
74 );
75 return;
76 }
77 props.onSnooze(snoozedUntil);
78 props.onClose();
79 };
80
81 return (
82 <Modal visible transparent animationType="fade" onRequestClose={props.onClose}>
83 <View className="flex-1 items-center justify-center bg-black/40 px-6">
84 <ScrollView
85 className="max-h-[80%] w-full max-w-sm grow-0 rounded-3xl border border-border bg-sheet-solid"
86 contentContainerStyle={{ padding: 20, gap: 16 }}
87 >
88 <View className="flex-row items-center justify-between gap-2">
89 <Pressable accessibilityRole="button" onPress={props.onClose} hitSlop={10}>
90 <AppText className="text-base">Cancel</AppText>
91 </Pressable>
92 <AppText accessibilityRole="header" className="text-base font-t3-semibold">
93 Custom snooze
94 </AppText>
95 <Pressable accessibilityRole="button" onPress={submit} hitSlop={10}>
96 <AppText className="text-base text-primary">Snooze</AppText>
97 </Pressable>
98 </View>
99 <SegmentedControl
100 options={modes}
101 selected={mode}
102 onSelect={(value) => {
103 setMode(value);
104 setError(null);
105 }}
106 role="tab"
107 />
108 <Host
109 matchContents={{ vertical: true }}
110 style={{ width: "100%" }}
111 colorScheme={themeAppearance}
112 seedColor={colors["--color-primary"]}
113 >
114 <HStack
115 spacing={6}
116 modifiers={[
117 frame({ minHeight: 44 }),
118 foregroundStyle(colors["--color-foreground"]),
119 font({ size: appearance.baseFontSize }),
120 ]}
121 >
122 <Text>{mode === "date" ? "Until" : "Snooze for"}</Text>
123 <Spacer />
124 {mode === "date" ? (
125 <>
126 <SnoozeDateField date={date} component="date" onChange={updateDate} />
127 <SnoozeDateField date={date} component="hourAndMinute" onChange={updateDate} />
128 </>
129 ) : (
130 <>
131 <Popover isPresented={amountOpen} onIsPresentedChange={setAmountOpen}>
132 <Popover.Trigger>
133 <Button
134 onPress={() => setAmountOpen(true)}
135 modifiers={[buttonStyle("plain")]}
136 >
137 <FieldLabel label={String(amount)} />
138 </Button>
139 </Popover.Trigger>
140 <Popover.Content>
141 <VStack
142 modifiers={[
143 padding({ all: 12 }),
144 frame({ width: 180 }),
145 presentationBackground(colors["--color-sheet-solid"]),
146 ]}
147 >
148 <Picker
149 label="Duration"
150 selection={amount}
151 onSelectionChange={(value: number) => {
152 setAmount(value);
153 setError(null);
154 }}
155 modifiers={[pickerStyle("wheel"), frame({ height: 160 })]}
156 >
157 {durationAmounts.map((value) => (
158 <Text
159 key={value}
160 modifiers={[
161 tag(value),
162 foregroundStyle(colors["--color-foreground"]),
163 ]}
164 >
165 {String(value)}
166 </Text>
167 ))}
168 </Picker>
169 <Button
170 label="Done"
171 onPress={() => setAmountOpen(false)}
172 modifiers={[foregroundStyle(colors["--color-primary"])]}
173 />
174 </VStack>
175 </Popover.Content>
176 </Popover>
177 <Menu
178 label={
179 <FieldLabel
180 label={unit === "minutes" ? "Minutes" : unit === "hours" ? "Hours" : "Days"}
181 />
182 }
183 >
184 {units.map((option) => (
185 <Button
186 key={option.value}
187 label={option.label}
188 onPress={() => {
189 setUnit(option.value);
190 setError(null);
191 }}
192 />
193 ))}
194 </Menu>
195 </>
196 )}
197 </HStack>
198 </Host>
199 {error && <AppText className="text-sm text-danger-foreground">{error}</AppText>}
200 </ScrollView>
201 </View>
202 </Modal>
203 );
204}
205
206function FieldLabel(props: { readonly label: string }) {
207 const { themeVariables: colors } = useAppearancePreferences();
208 return (
209 <Text
210 modifiers={[
211 padding({ horizontal: 12, vertical: 8 }),
212 foregroundStyle(colors["--color-foreground"]),
213 background(colors["--color-card"], shapes.capsule()),
214 ]}
215 >
216 {props.label}
217 </Text>
218 );
219}
220
221function SnoozeDateField(props: {
222 readonly date: Date;
223 readonly component: "date" | "hourAndMinute";
224 readonly onChange: (date: Date) => void;
225}) {
226 const [open, setOpen] = useState(false);
227 const { themeVariables: colors } = useAppearancePreferences();
228 return (
229 <Popover isPresented={open} onIsPresentedChange={setOpen}>
230 <Popover.Trigger>
231 <Button onPress={() => setOpen(true)} modifiers={[buttonStyle("plain")]}>
232 <FieldLabel
233 label={
234 props.component === "date"
235 ? props.date.toLocaleDateString(undefined, {
236 month: "short",
237 day: "numeric",
238 year: "numeric",
239 })
240 : props.date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
241 }
242 />
243 </Button>
244 </Popover.Trigger>
245 <Popover.Content>
246 <VStack
247 modifiers={[
248 padding({ all: 12 }),
249 frame({ width: props.component === "date" ? 340 : 300 }),
250 presentationBackground(colors["--color-sheet-solid"]),
251 ]}
252 >
253 <DatePicker
254 selection={props.date}
255 displayedComponents={[props.component]}
256 onDateChange={props.onChange}
257 modifiers={[datePickerStyle(props.component === "date" ? "graphical" : "wheel")]}
258 />
259 <Button
260 label="Done"
261 onPress={() => setOpen(false)}
262 modifiers={[foregroundStyle(colors["--color-primary"])]}
263 />
264 </VStack>
265 </Popover.Content>
266 </Popover>
267 );
268}
apps/mobile/src/features/threads/CustomSnoozeSheet.tsx created+182
...@@ -0,0 +1,182 @@
1import { DateTimePicker } from "@expo/ui/community/datetime-picker";
2import {
3 localSnoozeDate,
4 localSnoozeTime,
5 resolveCustomSnooze,
6 type CustomSnoozeInput,
7} from "@t3tools/client-runtime/state/thread-settled";
8import { useState } from "react";
9import {
10 KeyboardAvoidingView,
11 Modal,
12 Platform,
13 Pressable,
14 ScrollView,
15 TextInput,
16 View,
17} from "react-native";
18import { AppText } from "../../components/AppText";
19import { SegmentedControl } from "../../components/SegmentedControl";
20
21export function CustomSnoozeSheet(props: {
22 readonly onClose: () => void;
23 readonly onSnooze: (snoozedUntil: string) => void;
24}) {
25 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
26 const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
27 const [picker, setPicker] = useState<"date" | "time" | null>(null);
28 const [amount, setAmount] = useState("2");
29 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
30 const [error, setError] = useState<string | null>(null);
31 return (
32 <Modal visible transparent animationType="fade" onRequestClose={props.onClose}>
33 <KeyboardAvoidingView
34 className="flex-1 items-center justify-center bg-black/40 px-6"
35 behavior={Platform.OS === "ios" ? "padding" : undefined}
36 >
37 <ScrollView
38 className="max-h-[80%] w-full max-w-md grow-0 rounded-3xl bg-screen"
39 keyboardShouldPersistTaps="handled"
40 contentInsetAdjustmentBehavior="automatic"
41 contentContainerStyle={{ padding: 24, paddingBottom: 24, gap: 20 }}
42 >
43 <AppText accessibilityRole="header" className="text-xl font-t3-semibold">
44 Custom snooze
45 </AppText>
46 <AppText className="text-base text-foreground-secondary">
47 Choose when snoozed threads return to your inbox.
48 </AppText>
49 <SegmentedControl
50 options={
51 [
52 { value: "date", label: "Date and time" },
53 { value: "duration", label: "Duration" },
54 ] as const
55 }
56 selected={mode}
57 onSelect={(value) => {
58 setMode(value);
59 setPicker(null);
60 setError(null);
61 }}
62 role="tab"
63 />
64 {mode === "date" ? (
65 <View className="gap-3">
66 {(["date", "time"] as const).map((value) => (
67 <Pressable
68 key={value}
69 accessibilityRole="button"
70 accessibilityLabel={value === "date" ? "Choose date" : "Choose time"}
71 className="min-h-12 flex-row items-center justify-between rounded-xl bg-subtle px-3"
72 onPress={() => setPicker(value)}
73 >
74 <AppText>{value === "date" ? "Date" : "Time"}</AppText>
75 <AppText>
76 {value === "date"
77 ? date.toLocaleDateString()
78 : date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
79 </AppText>
80 </Pressable>
81 ))}
82 {picker && (
83 <DateTimePicker
84 value={date}
85 mode={picker}
86 display={Platform.OS === "ios" ? "spinner" : "default"}
87 onDismiss={() => setPicker(null)}
88 onValueChange={(_, selected) => {
89 const next = new Date(date);
90 if (picker === "date")
91 next.setFullYear(
92 selected.getFullYear(),
93 selected.getMonth(),
94 selected.getDate(),
95 );
96 else next.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
97 setDate(next);
98 setError(null);
99 if (Platform.OS === "android") setPicker(null);
100 }}
101 />
102 )}
103 </View>
104 ) : (
105 <View className="gap-3">
106 <AppText>Snooze for</AppText>
107 <TextInput
108 accessibilityLabel="Duration"
109 className="min-h-12 rounded-xl bg-subtle px-3 text-base text-foreground"
110 keyboardType="decimal-pad"
111 value={amount}
112 onChangeText={(value) => {
113 setAmount(value);
114 setError(null);
115 }}
116 />
117 <View className="flex-row gap-2">
118 {(["minutes", "hours", "days"] as const).map((value) => (
119 <Pressable
120 key={value}
121 accessibilityRole="radio"
122 accessibilityState={{ checked: unit === value }}
123 className={
124 unit === value
125 ? "min-h-12 flex-1 items-center justify-center rounded-xl bg-subtle"
126 : "min-h-12 flex-1 items-center justify-center rounded-xl"
127 }
128 onPress={() => {
129 setUnit(value);
130 setError(null);
131 }}
132 >
133 <AppText>
134 {value === "minutes" ? "Minutes" : value === "hours" ? "Hours" : "Days"}
135 </AppText>
136 </Pressable>
137 ))}
138 </View>
139 </View>
140 )}
141 {error && (
142 <AppText accessibilityRole="alert" className="text-danger-foreground">
143 {error}
144 </AppText>
145 )}
146 <View className="flex-row justify-end gap-3">
147 <Pressable
148 accessibilityRole="button"
149 className="min-h-12 justify-center px-3"
150 onPress={props.onClose}
151 >
152 <AppText>Cancel</AppText>
153 </Pressable>
154 <Pressable
155 accessibilityRole="button"
156 className="min-h-12 justify-center rounded-xl bg-primary px-3"
157 onPress={() => {
158 const input: CustomSnoozeInput =
159 mode === "date"
160 ? { mode, date: localSnoozeDate(date), time: localSnoozeTime(date) }
161 : { mode, amount: amount.replace(",", "."), unit };
162 const snoozedUntil = resolveCustomSnooze(input, new Date());
163 if (!snoozedUntil) {
164 setError(
165 mode === "date"
166 ? "Choose a date and time in the future."
167 : "Enter a positive duration.",
168 );
169 return;
170 }
171 props.onSnooze(snoozedUntil);
172 props.onClose();
173 }}
174 >
175 <AppText className="text-primary-foreground">Snooze</AppText>
176 </Pressable>
177 </View>
178 </ScrollView>
179 </KeyboardAvoidingView>
180 </Modal>
181 );
182}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+13-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";2import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2import { appAtomRegistry } from "../../state/atom-registry";3import { appAtomRegistry } from "../../state/atom-registry";
3import { threadArrangementOpenAtom } from "../../state/thread-order";4import { threadArrangementOpenAtom } from "../../state/thread-order";
...@@ -471,6 +472,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -471,6 +472,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
471 [onRegenerateThreadTitle, thread],472 [onRegenerateThreadTitle, thread],
472 );473 );
473 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);474 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
475 const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false);
474 const handleSnooze = useCallback(476 const handleSnooze = useCallback(
475 (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),477 (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),
476 [onSnoozeThread, thread],478 [onSnoozeThread, thread],
...@@ -508,12 +510,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -508,12 +510,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
508 [props.snoozePresetMinute, swipeActions.secondary],510 [props.snoozePresetMinute, swipeActions.secondary],
509 );511 );
510 const snoozePresetActions = useMemo<MenuAction[]>(512 const snoozePresetActions = useMemo<MenuAction[]>(
511 () =>513 () => [
512 snoozePresets.map((preset) => ({514 ...snoozePresets.map((preset) => ({
513 id: `snooze:${preset.id}`,515 id: `snooze:${preset.id}`,
514 title: preset.label,516 title: preset.label,
515 subtitle: preset.whenLabel,517 subtitle: preset.whenLabel,
516 })),518 })),
519 { id: "snooze:custom", title: "Custom…" },
520 ],
517 [snoozePresets],521 [snoozePresets],
518 );522 );
519 // Pinned cards keep the full lifecycle menu; only the pin item flips to523 // Pinned cards keep the full lifecycle menu; only the pin item flips to
...@@ -625,6 +629,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -625,6 +629,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
625 if (nativeEvent.event === "archive") handleArchive();629 if (nativeEvent.event === "archive") handleArchive();
626 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();630 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
627 if (nativeEvent.event === "delete") handleDelete();631 if (nativeEvent.event === "delete") handleDelete();
632 if (nativeEvent.event === "snooze:custom") {
633 setCustomSnoozeOpen(true);
634 return;
635 }
628 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({636 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
629 event: nativeEvent.event,637 event: nativeEvent.event,
630 displayedPresets: snoozePresets,638 displayedPresets: snoozePresets,
...@@ -1056,6 +1064,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1056,6 +1064,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
10561064
1057 return (1065 return (
1058 <>1066 <>
1067 {customSnoozeOpen && (
1068 <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />
1069 )}
1059 <ThreadSwipeable1070 <ThreadSwipeable
1060 threadKey={`${thread.environmentId}:${thread.id}`}1071 threadKey={`${thread.environmentId}:${thread.id}`}
1061 backgroundColor={sidebarPane ? drawerColor : screenColor}1072 backgroundColor={sidebarPane ? drawerColor : screenColor}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+2-71
...@@ -19,9 +19,10 @@ import {...@@ -19,9 +19,10 @@ import {
19} from "@t3tools/shared/usageFormat";19} from "@t3tools/shared/usageFormat";
20import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";20import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
21import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";21import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
22import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated";22import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";
23import { useSafeAreaInsets } from "react-native-safe-area-context";23import { useSafeAreaInsets } from "react-native-safe-area-context";
2424
25import { SegmentedControl } from "../../components/SegmentedControl";
25import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";26import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
26import { AppText as Text } from "../../components/AppText";27import { AppText as Text } from "../../components/AppText";
27import { cn } from "../../lib/cn";28import { cn } from "../../lib/cn";
...@@ -332,76 +333,6 @@ export function UsageRouteScreen() {...@@ -332,76 +333,6 @@ export function UsageRouteScreen() {
332 );333 );
333}334}
334335
335function SegmentedControl<Value extends number | string>(props: {
336 readonly options: readonly {
337 readonly value: Value;
338 readonly label: string;
339 readonly accessibilityLabel?: string;
340 }[];
341 readonly selected: Value;
342 readonly onSelect: (value: Value) => void;
343 /** The tab bar is full height; filters under it are shorter so it stays primary. */
344 readonly size?: "default" | "compact";
345 /** "tab" for the view switcher; filters stay plain buttons. */
346 readonly role?: "tab" | "button";
347 readonly className?: string;
348}) {
349 const compact = props.size === "compact";
350 return (
351 <View
352 accessible={false}
353 className={cn(
354 "flex-row overflow-hidden rounded-full border-continuous bg-card",
355 props.className,
356 )}
357 >
358 <Animated.View
359 pointerEvents="none"
360 layout={LinearTransition.duration(200)
361 .easing(Easing.out(Easing.cubic))
362 .reduceMotion(ReduceMotion.System)}
363 className="absolute bottom-0 top-0 rounded-full bg-subtle-strong"
364 style={{
365 width: `${100 / props.options.length}%`,
366 start: `${
367 (Math.max(
368 0,
369 props.options.findIndex((option) => option.value === props.selected),
370 ) *
371 100) /
372 props.options.length
373 }%`,
374 }}
375 />
376 {props.options.map((option) => {
377 const active = option.value === props.selected;
378 return (
379 <Pressable
380 key={String(option.value)}
381 accessibilityRole={Platform.OS === "ios" ? "button" : (props.role ?? "button")}
382 accessibilityLabel={option.accessibilityLabel ?? option.label}
383 accessibilityState={{ selected: active }}
384 onPress={() => props.onSelect(option.value)}
385 className={cn(
386 "flex-1 items-center justify-center rounded-full",
387 compact ? "h-9" : "h-11",
388 )}
389 >
390 <Text
391 className={cn(
392 compact ? "text-xs" : "text-sm",
393 active ? "font-t3-medium text-foreground" : "text-foreground-muted",
394 )}
395 >
396 {option.label}
397 </Text>
398 </Pressable>
399 );
400 })}
401 </View>
402 );
403}
404
405/** Headline figure, the animated daily chart, and its legend, in one card. */336/** Headline figure, the animated daily chart, and its legend, in one card. */
406function ChartCard(props: {337function ChartCard(props: {
407 readonly merged: MergedUsage;338 readonly merged: MergedUsage;
apps/web/package.json+1
...@@ -15,6 +15,7 @@...@@ -15,6 +15,7 @@
15 "@base-ui/react": "^1.4.1",15 "@base-ui/react": "^1.4.1",
16 "@clerk/electron": "catalog:",16 "@clerk/electron": "catalog:",
17 "@clerk/react": "catalog:",17 "@clerk/react": "catalog:",
18 "@daypicker/react": "^10.0.1",
18 "@dnd-kit/core": "^6.3.1",19 "@dnd-kit/core": "^6.3.1",
19 "@dnd-kit/modifiers": "^9.0.0",20 "@dnd-kit/modifiers": "^9.0.0",
20 "@dnd-kit/sortable": "^10.0.0",21 "@dnd-kit/sortable": "^10.0.0",
apps/web/src/components/CustomSnoozeDialog.tsx created+245
...@@ -0,0 +1,245 @@
1import { useEffect, useId, useState } from "react";
2import { Tabs } from "@base-ui/react/tabs";
3import { create } from "zustand";
4import {
5 localSnoozeDate,
6 localSnoozeTime,
7 resolveCustomSnooze,
8 type CustomSnoozeInput,
9} from "@t3tools/client-runtime/state/thread-settled";
10import { Button } from "./ui/button";
11import { CalendarIcon } from "lucide-react";
12import { Calendar } from "./ui/calendar";
13import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover";
14import { Input } from "./ui/input";
15import { Label } from "./ui/label";
16import { toggleVariants } from "./ui/toggle";
17import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select";
18import {
19 NumberField,
20 NumberFieldGroup,
21 NumberFieldInput,
22 NumberFieldDecrement,
23 NumberFieldIncrement,
24} from "./ui/number-field";
25import {
26 Dialog,
27 DialogPopup,
28 DialogHeader,
29 DialogTitle,
30 DialogDescription,
31 DialogPanel,
32 DialogFooter,
33} from "./ui/dialog";
34
35type SnoozeChoice = { readonly snoozedUntil: string };
36type Request = { readonly resolve: (choice: SnoozeChoice | null) => void };
37const useRequest = create<{ request: Request | null }>(() => ({ request: null }));
38
39export function requestCustomSnooze(): Promise<SnoozeChoice | null> {
40 useRequest.getState().request?.resolve(null);
41 return new Promise((resolve) => useRequest.setState({ request: { resolve } }));
42}
43
44function finish(choice: SnoozeChoice | null) {
45 const request = useRequest.getState().request;
46 useRequest.setState({ request: null });
47 request?.resolve(choice);
48}
49
50export function CustomSnoozeDialogHost() {
51 const request = useRequest((state) => state.request);
52 useEffect(() => () => finish(null), []);
53 return request ? <CustomSnoozeDialog /> : null;
54}
55
56function CustomSnoozeDialog() {
57 const id = useId();
58 const [initial] = useState(() => new Date(Date.now() + 3_600_000));
59 const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date");
60 const [date, setDate] = useState(initial);
61 const [calendarOpen, setCalendarOpen] = useState(false);
62 const [time, setTime] = useState(localSnoozeTime(initial));
63 const [amount, setAmount] = useState("2");
64 const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
65 const [error, setError] = useState<string | null>(null);
66 const input: CustomSnoozeInput =
67 mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit };
68 return (
69 <Dialog
70 open
71 onOpenChange={(open) => {
72 if (!open) finish(null);
73 }}
74 >
75 <DialogPopup className="sm:max-w-sm">
76 <form
77 className="flex min-h-0 flex-col"
78 onSubmit={(event) => {
79 event.preventDefault();
80 const snoozedUntil = resolveCustomSnooze(input, new Date());
81 if (!snoozedUntil) {
82 setError(
83 mode === "date"
84 ? "Choose a valid date and time in the future."
85 : "Enter a positive duration.",
86 );
87 return;
88 }
89 finish({ snoozedUntil });
90 }}
91 >
92 <DialogHeader>
93 <DialogTitle>Custom snooze</DialogTitle>
94 <DialogDescription>Choose when snoozed threads return to your inbox.</DialogDescription>
95 </DialogHeader>
96 <DialogPanel className="flex flex-col gap-4 text-base sm:text-sm">
97 <Tabs.Root
98 value={mode}
99 onValueChange={(value) => {
100 if (value === "date" || value === "duration") setMode(value);
101 setError(null);
102 }}
103 className="flex flex-col gap-4"
104 >
105 <Tabs.List
106 aria-label="Schedule type"
107 className="flex gap-0.5 rounded-lg bg-input/40 p-0.5"
108 >
109 {(["date", "duration"] as const).map((value) => (
110 <Tabs.Tab
111 key={value}
112 value={value}
113 data-pressed={mode === value ? "" : undefined}
114 className={toggleVariants({
115 variant: "segmented",
116 size: "sm",
117 className: "flex-1",
118 })}
119 >
120 {value === "date" ? "Date and time" : "Duration"}
121 </Tabs.Tab>
122 ))}
123 </Tabs.List>
124 <Tabs.Panel value={mode} className="flex flex-col gap-4">
125 {mode === "date" ? (
126 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
127 <div className="flex min-w-0 flex-col gap-1.5">
128 <Label htmlFor={`${id}-date`}>Date</Label>
129 <Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
130 <PopoverTrigger
131 render={
132 <Button
133 id={`${id}-date`}
134 variant="outline"
135 className="w-full justify-between font-normal"
136 />
137 }
138 >
139 {date.toLocaleDateString(undefined, {
140 month: "short",
141 day: "numeric",
142 year: "numeric",
143 })}
144 <CalendarIcon className="size-4 text-muted-foreground" />
145 </PopoverTrigger>
146 <PopoverPopup align="start" aria-label="Choose snooze date">
147 <Calendar
148 mode="single"
149 required
150 selected={date}
151 defaultMonth={date}
152 disabled={{ before: new Date(new Date().setHours(0, 0, 0, 0)) }}
153 onSelect={(selected) => {
154 setDate(selected);
155 setCalendarOpen(false);
156 setError(null);
157 }}
158 />
159 </PopoverPopup>
160 </Popover>
161 </div>
162 <Label
163 className="flex min-w-0 flex-col items-stretch gap-1.5"
164 htmlFor={`${id}-time`}
165 >
166 Time
167 <Input
168 nativeInput
169 id={`${id}-time`}
170 className="h-9 sm:h-8"
171 type="time"
172 required
173 value={time}
174 onChange={(event) => {
175 setTime(event.target.value);
176 setError(null);
177 }}
178 />
179 </Label>
180 </div>
181 ) : (
182 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
183 <NumberField
184 className="gap-1.5"
185 id={`${id}-amount`}
186 min={0}
187 step="any"
188 value={amount === "" ? null : Number(amount)}
189 onValueChange={(value) => {
190 setAmount(value === null ? "" : String(value));
191 setError(null);
192 }}
193 >
194 <Label htmlFor={`${id}-amount`}>Snooze for</Label>
195 <NumberFieldGroup>
196 <NumberFieldDecrement aria-label="Decrease duration" />
197 <NumberFieldInput required />
198 <NumberFieldIncrement aria-label="Increase duration" />
199 </NumberFieldGroup>
200 </NumberField>
201 <Label
202 className="flex min-w-0 flex-col items-stretch gap-1.5"
203 htmlFor={`${id}-unit`}
204 >
205 Unit
206 <Select
207 value={unit}
208 items={{ minutes: "Minutes", hours: "Hours", days: "Days" }}
209 onValueChange={(value) => {
210 if (value === "minutes" || value === "hours" || value === "days")
211 setUnit(value);
212 setError(null);
213 }}
214 >
215 <SelectTrigger id={`${id}-unit`} className="min-w-0">
216 <SelectValue />
217 </SelectTrigger>
218 <SelectPopup>
219 <SelectItem value="minutes">Minutes</SelectItem>
220 <SelectItem value="hours">Hours</SelectItem>
221 <SelectItem value="days">Days</SelectItem>
222 </SelectPopup>
223 </Select>
224 </Label>
225 </div>
226 )}
227 </Tabs.Panel>
228 </Tabs.Root>
229 {error && (
230 <p role="alert" className="text-destructive">
231 {error}
232 </p>
233 )}
234 </DialogPanel>
235 <DialogFooter>
236 <Button type="button" variant="outline" onClick={() => finish(null)}>
237 Cancel
238 </Button>
239 <Button type="submit">Snooze</Button>
240 </DialogFooter>
241 </form>
242 </DialogPopup>
243 </Dialog>
244 );
245}
apps/web/src/components/Sidebar.tsx+34-15
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { requestCustomSnooze } from "./CustomSnoozeDialog";
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";2import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";3import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
3import { useAtomValue } from "@effect/atom-react";4import { useAtomValue } from "@effect/atom-react";
...@@ -439,7 +440,7 @@ function SidebarThreadTooltip({...@@ -439,7 +440,7 @@ function SidebarThreadTooltip({
439function SnoozePopoverButton(props: {440function SnoozePopoverButton(props: {
440 open: boolean;441 open: boolean;
441 onOpenChange: (open: boolean) => void;442 onOpenChange: (open: boolean) => void;
442 onSnooze: (preset: SnoozePreset) => void;443 onSnooze: (preset: Pick<SnoozePreset, "snoozedUntil">) => void;
443 timestampFormat: TimestampFormat;444 timestampFormat: TimestampFormat;
444}) {445}) {
445 const { open, onOpenChange, onSnooze, timestampFormat } = props;446 const { open, onOpenChange, onSnooze, timestampFormat } = props;
...@@ -489,6 +490,19 @@ function SnoozePopoverButton(props: {...@@ -489,6 +490,19 @@ function SnoozePopoverButton(props: {
489 </span>490 </span>
490 </button>491 </button>
491 ))}492 ))}
493 <div className="my-1 border-t border-border/60" />
494 <button
495 type="button"
496 className="flex w-full cursor-pointer rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground"
497 onClick={async (event) => {
498 event.stopPropagation();
499 onOpenChange(false);
500 const choice = await requestCustomSnooze();
501 if (choice) onSnooze(choice);
502 }}
503 >
504 Custom…
505 </button>
492 </PopoverPopup>506 </PopoverPopup>
493 </Popover>507 </Popover>
494 );508 );
...@@ -999,7 +1013,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -999,7 +1013,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
999 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;1013 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
1000 onSettle: (threadRef: ScopedThreadRef) => void;1014 onSettle: (threadRef: ScopedThreadRef) => void;
1001 onUnsettle: (threadRef: ScopedThreadRef) => void;1015 onUnsettle: (threadRef: ScopedThreadRef) => void;
1002 onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;1016 onSnooze: (threadRef: ScopedThreadRef, preset: Pick<SnoozePreset, "snoozedUntil">) => void;
1003 onUnsnooze: (threadRef: ScopedThreadRef) => void;1017 onUnsnooze: (threadRef: ScopedThreadRef) => void;
1004 onUnpin: (threadRef: ScopedThreadRef) => void;1018 onUnpin: (threadRef: ScopedThreadRef) => void;
1005 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;1019 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
...@@ -1333,7 +1347,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1333,7 +1347,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1333 [onUnpin, threadRef],1347 [onUnpin, threadRef],
1334 );1348 );
1335 const handleSnoozePreset = useCallback(1349 const handleSnoozePreset = useCallback(
1336 (preset: SnoozePreset) => {1350 (preset: Pick<SnoozePreset, "snoozedUntil">) => {
1337 onSnooze(threadRef, preset);1351 onSnooze(threadRef, preset);
1338 },1352 },
1339 [onSnooze, threadRef],1353 [onSnooze, threadRef],
...@@ -3644,7 +3658,7 @@ export default function Sidebar() {...@@ -3644,7 +3658,7 @@ export default function Sidebar() {
3644 const performSnooze = useCallback(3658 const performSnooze = useCallback(
3645 async (3659 async (
3646 threadRef: ScopedThreadRef,3660 threadRef: ScopedThreadRef,
3647 preset: SnoozePreset,3661 preset: Pick<SnoozePreset, "snoozedUntil">,
3648 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},3662 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
3649 ) => {3663 ) => {
3650 const threadKey = scopedThreadKey(threadRef);3664 const threadKey = scopedThreadKey(threadRef);
...@@ -3678,7 +3692,7 @@ export default function Sidebar() {...@@ -3678,7 +3692,7 @@ export default function Sidebar() {
3678 const attemptSnooze = useCallback(3692 const attemptSnooze = useCallback(
3679 (3693 (
3680 threadRef: ScopedThreadRef,3694 threadRef: ScopedThreadRef,
3681 preset: SnoozePreset,3695 preset: Pick<SnoozePreset, "snoozedUntil">,
3682 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},3696 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
3683 ) => {3697 ) => {
3684 void (async () => {3698 void (async () => {
...@@ -3774,10 +3788,13 @@ export default function Sidebar() {...@@ -3774,10 +3788,13 @@ export default function Sidebar() {
3774 {3788 {
3775 id: "snooze",3789 id: "snooze",
3776 label: `Snooze (${count})`,3790 label: `Snooze (${count})`,
3777 children: snoozePresets.map((preset) => ({3791 children: [
3778 id: `snooze:${preset.id}`,3792 ...snoozePresets.map((preset) => ({
3779 label: `${preset.label} (${preset.whenLabel})`,3793 id: `snooze:${preset.id}`,
3780 })),3794 label: `${preset.label} (${preset.whenLabel})`,
3795 })),
3796 { id: "snooze:custom", label: "Custom…", separatorBefore: true },
3797 ],
3781 },3798 },
3782 ]3799 ]
3783 : []),3800 : []),
...@@ -3790,9 +3807,10 @@ export default function Sidebar() {...@@ -3790,9 +3807,10 @@ export default function Sidebar() {
3790 );3807 );
3791 if (clicked._tag === "Failure") return;3808 if (clicked._tag === "Failure") return;
3792 if (clicked.value?.startsWith("snooze:")) {3809 if (clicked.value?.startsWith("snooze:")) {
3793 const preset = snoozePresets.find(3810 const preset =
3794 (candidate) => `snooze:${candidate.id}` === clicked.value,3811 clicked.value === "snooze:custom"
3795 );3812 ? await requestCustomSnooze()
3813 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
3796 if (preset) {3814 if (preset) {
3797 // Post-snooze navigation must skip threads snoozing in this same3815 // Post-snooze navigation must skip threads snoozing in this same
3798 // batch — they are all leaving the card block together.3816 // batch — they are all leaving the card block together.
...@@ -4019,9 +4037,10 @@ export default function Sidebar() {...@@ -4019,9 +4037,10 @@ export default function Sidebar() {
4019 );4037 );
4020 if (clicked._tag === "Failure") return;4038 if (clicked._tag === "Failure") return;
4021 if (clicked.value?.startsWith("snooze:")) {4039 if (clicked.value?.startsWith("snooze:")) {
4022 const preset = snoozePresets.find(4040 const preset =
4023 (candidate) => `snooze:${candidate.id}` === clicked.value,4041 clicked.value === "snooze:custom"
4024 );4042 ? await requestCustomSnooze()
4043 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
4025 if (preset) attemptSnooze(threadRef, preset);4044 if (preset) attemptSnooze(threadRef, preset);
4026 return;4045 return;
4027 }4046 }
apps/web/src/components/threadActionMenu.logic.test.ts+1-1
...@@ -67,7 +67,7 @@ describe("buildThreadActionMenuItems", () => {...@@ -67,7 +67,7 @@ describe("buildThreadActionMenuItems", () => {
67 (item) => item.id === "snooze",67 (item) => item.id === "snooze",
68 );68 );
69 expect(snooze?.disabled).toBe(true);69 expect(snooze?.disabled).toBe(true);
70 expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour"]);70 expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour", "snooze:custom"]);
71 });71 });
7272
73 it("disables title regeneration while one is in flight", () => {73 it("disables title regeneration while one is in flight", () => {
apps/web/src/components/threadActionMenu.logic.ts+7-4
...@@ -88,10 +88,13 @@ export function buildThreadActionMenuItems(...@@ -88,10 +88,13 @@ export function buildThreadActionMenuItems(
88 label: "Snooze",88 label: "Snooze",
89 icon: "clock",89 icon: "clock",
90 disabled: !state.canSnoozeNow,90 disabled: !state.canSnoozeNow,
91 children: state.snoozePresets.map((preset) => ({91 children: [
92 id: `snooze:${preset.id}` as const,92 ...state.snoozePresets.map((preset) => ({
93 label: `${preset.label} (${preset.whenLabel})`,93 id: `snooze:${preset.id}` as const,
94 })),94 label: `${preset.label} (${preset.whenLabel})`,
95 })),
96 { id: "snooze:custom" as const, label: "Custom…", separatorBefore: true },
97 ],
95 },98 },
96 ]99 ]
97 : []),100 : []),
apps/web/src/components/ui/calendar.tsx created+112
...@@ -0,0 +1,112 @@
1"use client";
2
3import { DayPicker } from "@daypicker/react";
4import { ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon } from "lucide-react";
5import type * as React from "react";
6import { cn } from "~/lib/utils";
7
8const buttonClassNames =
9 "relative flex size-(--cell-size) text-base sm:text-sm items-center justify-center rounded-lg text-foreground not-in-data-selected:hover:bg-accent disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0";
10
11const defaultComponents = {
12 Chevron: ({
13 className,
14 orientation,
15 ...props
16 }: {
17 className?: string;
18 orientation?: "left" | "right" | "up" | "down";
19 }): React.ReactElement => {
20 if (orientation === "left") {
21 return (
22 <ChevronLeftIcon
23 className={cn(className, "rtl:rotate-180")}
24 {...props}
25 aria-hidden="true"
26 />
27 );
28 }
29
30 if (orientation === "right") {
31 return (
32 <ChevronRightIcon
33 className={cn(className, "rtl:rotate-180")}
34 {...props}
35 aria-hidden="true"
36 />
37 );
38 }
39
40 return <ChevronsUpDownIcon className={className} {...props} aria-hidden="true" />;
41 },
42};
43
44export function Calendar({
45 className,
46 classNames,
47 showOutsideDays = true,
48 components: userComponents,
49 mode = "single",
50 ...props
51}: React.ComponentProps<typeof DayPicker>): React.ReactElement {
52 const defaultClassNames = {
53 button_next: buttonClassNames,
54 button_previous: buttonClassNames,
55 caption_label: "text-base sm:text-sm font-medium flex items-center gap-2 h-full",
56 day: "size-(--cell-size) text-sm py-px",
57 day_button: cn(
58 buttonClassNames,
59 "in-data-disabled:pointer-events-none in-[.range-middle]:rounded-none in-[.range-end:not(.range-start)]:rounded-s-none in-[.range-start:not(.range-end)]:rounded-e-none in-[.range-middle]:in-data-selected:bg-accent in-data-selected:bg-primary in-[.range-middle]:in-data-selected:text-foreground in-data-disabled:text-muted-foreground/72 in-data-outside:text-muted-foreground/72 in-data-selected:in-data-outside:text-primary-foreground in-data-selected:text-primary-foreground in-data-disabled:line-through outline-none in-[[data-selected]:not(.range-middle)]:transition-[border-radius,box-shadow] focus-visible:z-1 focus-visible:ring-[3px] focus-visible:ring-ring/50",
60 ),
61 dropdown: "absolute bg-popover inset-0 opacity-0",
62 dropdown_root:
63 "relative has-focus:border-ring has-focus:ring-ring/50 has-focus:ring-[3px] border border-input shadow-xs/5 rounded-lg px-[calc(--spacing(3)-1px)] h-9 sm:h-8 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-me-1",
64 dropdowns:
65 "w-full flex items-center text-base sm:text-sm justify-center h-(--cell-size) gap-1.5 *:[span]:font-medium",
66 hidden: "invisible",
67 month: "w-full",
68 month_caption:
69 "relative mx-(--cell-size) px-1 mb-1 flex h-(--cell-size) items-center justify-center z-2",
70 months: "relative flex flex-col sm:flex-row gap-2",
71 nav: "absolute top-0 flex w-full justify-between z-1",
72 outside: "text-muted-foreground data-selected:bg-accent/50 data-selected:text-muted-foreground",
73 range_end: "range-end",
74 range_middle: "range-middle",
75 range_start: "range-start",
76 today:
77 "*:after:pointer-events-none *:after:absolute *:after:bottom-1 *:after:start-1/2 *:after:z-1 *:after:size-[3px] *:after:-translate-x-1/2 *:after:rounded-full *:after:bg-primary [&[data-selected]:not(.range-middle)>*]:after:bg-background [&[data-disabled]>*]:after:bg-foreground/30",
78 week_number: "size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72",
79 weekday: "size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72",
80 };
81 const mergedClassNames: typeof defaultClassNames = Object.keys(defaultClassNames).reduce(
82 (acc, key) => {
83 const userClass = classNames?.[key as keyof typeof classNames];
84 const baseClass = defaultClassNames[key as keyof typeof defaultClassNames];
85
86 acc[key as keyof typeof defaultClassNames] = userClass ? cn(baseClass, userClass) : baseClass;
87
88 return acc;
89 },
90 { ...defaultClassNames } as typeof defaultClassNames,
91 );
92
93 const mergedComponents = {
94 ...defaultComponents,
95 ...userComponents,
96 };
97
98 const dayPickerProps = {
99 className: cn("w-fit [--cell-size:--spacing(10)] sm:[--cell-size:--spacing(9)]", className),
100 classNames: mergedClassNames,
101 components: mergedComponents,
102 "data-slot": "calendar",
103 formatters: {
104 formatMonthDropdown: (date: Date) => date.toLocaleString("default", { month: "short" }),
105 } as React.ComponentProps<typeof DayPicker>["formatters"],
106 mode,
107 showOutsideDays,
108 ...props,
109 };
110
111 return <DayPicker {...(dayPickerProps as React.ComponentProps<typeof DayPicker>)} />;
112}
apps/web/src/hooks/useThreadActionMenu.ts+5-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { requestCustomSnooze } from "../components/CustomSnoozeDialog";
1import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment";2import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
2import {3import {
3 type AtomCommandResult,4 type AtomCommandResult,
...@@ -152,7 +153,10 @@ export function useThreadActionMenu(input: {...@@ -152,7 +153,10 @@ export function useThreadActionMenu(input: {
152 if (clicked._tag === "Failure" || clicked.value === null) return;153 if (clicked._tag === "Failure" || clicked.value === null) return;
153 const action: ThreadActionMenuId = clicked.value;154 const action: ThreadActionMenuId = clicked.value;
154 if (action.startsWith("snooze:")) {155 if (action.startsWith("snooze:")) {
155 const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action);156 const preset =
157 action === "snooze:custom"
158 ? await requestCustomSnooze()
159 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === action);
156 if (!preset) return;160 if (!preset) return;
157 const result = await snoozeThread(threadRef, preset.snoozedUntil);161 const result = await snoozeThread(threadRef, preset.snoozedUntil);
158 if (result._tag === "Failure") {162 if (result._tag === "Failure") {
apps/web/src/routes/__root.tsx+3
...@@ -18,6 +18,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "....@@ -18,6 +18,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from ".
18import { resolveServerBackedAppDisplayName } from "../branding.logic";18import { resolveServerBackedAppDisplayName } from "../branding.logic";
19import { AppSidebarLayout } from "../components/AppSidebarLayout";19import { AppSidebarLayout } from "../components/AppSidebarLayout";
20import { CommandPalette } from "../components/CommandPalette";20import { CommandPalette } from "../components/CommandPalette";
21import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog";
21import { ConfirmDialogHost } from "../components/ConfirmDialogHost";22import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
22import { FirstRunGate } from "../components/onboarding/FirstRunGate";23import { FirstRunGate } from "../components/onboarding/FirstRunGate";
23import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";24import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
...@@ -171,6 +172,7 @@ function RootRouteView() {...@@ -171,6 +172,7 @@ function RootRouteView() {
171 <EnvironmentThemeSync />172 <EnvironmentThemeSync />
172 <GlassAppearanceSync />173 <GlassAppearanceSync />
173 <FontAppearanceSync />174 <FontAppearanceSync />
175 <CustomSnoozeDialogHost />
174 <CommandPalette>176 <CommandPalette>
175 <AppSidebarLayout>177 <AppSidebarLayout>
176 <Outlet />178 <Outlet />
...@@ -222,6 +224,7 @@ function RootRouteView() {...@@ -222,6 +224,7 @@ function RootRouteView() {
222 <SnapShotCoordinator />224 <SnapShotCoordinator />
223 <ThreadNotificationCoordinator />225 <ThreadNotificationCoordinator />
224 <ConfirmDialogHost />226 <ConfirmDialogHost />
227 <CustomSnoozeDialogHost />
225 <SlowRpcRequestToastCoordinator />228 <SlowRpcRequestToastCoordinator />
226 <ProjectCloneToastCoordinator />229 <ProjectCloneToastCoordinator />
227 <HostedStaticEnvironmentBootstrap />230 <HostedStaticEnvironmentBootstrap />
docs/user/thread-sidebar.md+7
...@@ -125,3 +125,10 @@ On web and desktop, use **Agents** to follow work delegated to subagents....@@ -125,3 +125,10 @@ On web and desktop, use **Agents** to follow work delegated to subagents.
125Expand a tool call in the conversation to see its full command and output.125Expand a tool call in the conversation to see its full command and output.
126Summaries shorten shell wrappers and can still describe the latest call after it126Summaries shorten shell wrappers and can still describe the latest call after it
127finishes; the call's own result shows its status.127finishes; the call's own result shows its status.
128
129## Snooze until later
130
131Choose **Snooze → Custom…** from a thread's menu to pick a date and time in your
132local time zone, or a duration in minutes, hours, or days. Durations start when
133you confirm; one day means 24 hours. On web and desktop, you can also snooze
134several selected threads together. Choose **Wake thread** to bring a thread back early.
packages/client-runtime/src/state/customSnooze.test.ts created+66
...@@ -0,0 +1,66 @@
1// @effect-diagnostics globalDate:off -- Tests exercise local calendar and elapsed-time snooze input.
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3import { localSnoozeDate, localSnoozeTime, resolveCustomSnooze } from "./threadSettled.ts";
4
5const now = new Date(2026, 8, 14, 14, 30);
6
7afterEach(() => vi.unstubAllEnvs());
8
9describe("custom snooze", () => {
10 it("converts local date and time to an absolute wake time", () => {
11 expect(resolveCustomSnooze({ mode: "date", date: "2026-09-15", time: "09:15" }, now)).toBe(
12 new Date(2026, 8, 15, 9, 15).toISOString(),
13 );
14 });
15
16 it.each([
17 ["2026-09-14", "14:30"],
18 ["2026-09-13", "09:00"],
19 ["2027-02-29", "09:00"],
20 ["2026-13-01", "09:00"],
21 ["2026-09-15", "24:00"],
22 ["2026-09-15", "09:60"],
23 ["", "09:00"],
24 ["2026-09-15", ""],
25 ])("rejects invalid or non-future calendar input %s %s", (date, time) => {
26 expect(resolveCustomSnooze({ mode: "date", date, time }, now)).toBeNull();
27 });
28
29 it.each([
30 ["minutes", "45", 45 * 60_000],
31 ["hours", "1.5", 90 * 60_000],
32 ["days", "2", 48 * 3_600_000],
33 ] as const)("resolves %s from the confirmation time", (unit, amount, elapsed) => {
34 const confirmedAt = new Date(now.getTime() + 5 * 60_000);
35 expect(resolveCustomSnooze({ mode: "duration", amount, unit }, confirmedAt)).toBe(
36 new Date(confirmedAt.getTime() + elapsed).toISOString(),
37 );
38 });
39
40 it.each(["", "0", "-1", "NaN", "Infinity", "1e300"])("rejects invalid duration %s", (amount) => {
41 expect(resolveCustomSnooze({ mode: "duration", amount, unit: "hours" }, now)).toBeNull();
42 });
43
44 it("rejects nonexistent local times at the spring DST transition", () => {
45 vi.stubEnv("TZ", "America/Los_Angeles");
46 expect(
47 resolveCustomSnooze(
48 { mode: "date", date: "2027-03-14", time: "02:30" },
49 new Date("2027-03-01T00:00:00Z"),
50 ),
51 ).toBeNull();
52 });
53
54 it("treats duration days as 24 hours across DST", () => {
55 vi.stubEnv("TZ", "America/Los_Angeles");
56 const before = new Date(2027, 2, 13, 12);
57 const wake = resolveCustomSnooze({ mode: "duration", amount: "1", unit: "days" }, before);
58 expect(wake).toBe(new Date(2027, 2, 14, 13).toISOString());
59 });
60
61 it("formats local input values without converting to UTC", () => {
62 const date = new Date(2026, 0, 2, 3, 4);
63 expect(localSnoozeDate(date)).toBe("2026-01-02");
64 expect(localSnoozeTime(date)).toBe("03:04");
65 });
66});
packages/client-runtime/src/state/threadSettled.ts+35
...@@ -274,3 +274,38 @@ export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: s...@@ -274,3 +274,38 @@ export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: s
274 if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`;274 if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`;
275 return `${Math.ceil(remainingMs / DAY_MS)}d`;275 return `${Math.ceil(remainingMs / DAY_MS)}d`;
276}276}
277
278export type CustomSnoozeInput =
279 | { readonly mode: "date"; readonly date: string; readonly time: string }
280 | {
281 readonly mode: "duration";
282 readonly amount: string;
283 readonly unit: "minutes" | "hours" | "days";
284 };
285
286/** Resolve local calendar input or elapsed time, rejecting past and invalid dates. */
287export function resolveCustomSnooze(input: CustomSnoozeInput, now: Date): string | null {
288 let wake: Date;
289 if (input.mode === "duration") {
290 const amount = Number(input.amount);
291 if (!Number.isFinite(amount) || amount <= 0) return null;
292 const unitMs = { minutes: 60_000, hours: HOUR_MS, days: 24 * HOUR_MS }[input.unit];
293 wake = new Date(now.getTime() + amount * unitMs);
294 } else {
295 if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date) || !/^\d{2}:\d{2}$/.test(input.time)) return null;
296 wake = new Date(`${input.date}T${input.time}:00`);
297 // Reject rolled-over dates and nonexistent local times during DST changes.
298 if (localSnoozeDate(wake) !== input.date || localSnoozeTime(wake) !== input.time) return null;
299 }
300 return Number.isFinite(wake.getTime()) && wake.getTime() > now.getTime()
301 ? wake.toISOString()
302 : null;
303}
304
305export function localSnoozeDate(date: Date): string {
306 return `${String(date.getFullYear()).padStart(4, "0")}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
307}
308
309export function localSnoozeTime(date: Date): string {
310 return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
311}
pnpm-lock.yaml+54-4
...@@ -569,13 +569,16 @@ importers:...@@ -569,13 +569,16 @@ importers:
569 dependencies:569 dependencies:
570 '@base-ui/react':570 '@base-ui/react':
571 specifier: ^1.4.1571 specifier: ^1.4.1
572 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)572 version: 1.5.0(@date-fns/tz@1.5.0)(@types/react@19.2.16)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
573 '@clerk/electron':573 '@clerk/electron':
574 specifier: 0.0.42574 specifier: 0.0.42
575 version: 0.0.42(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)575 version: 0.0.42(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
576 '@clerk/react':576 '@clerk/react':
577 specifier: 6.15.2577 specifier: 6.15.2
578 version: 6.15.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)578 version: 6.15.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
579 '@daypicker/react':
580 specifier: ^10.0.1
581 version: 10.0.1(@types/react@19.2.16)(react@19.2.6)
579 '@dnd-kit/core':582 '@dnd-kit/core':
580 specifier: ^6.3.1583 specifier: ^6.3.1
581 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)584 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
...@@ -774,7 +777,7 @@ importers:...@@ -774,7 +777,7 @@ importers:
774 version: link:../../packages/shared777 version: link:../../packages/shared
775 alchemy:778 alchemy:
776 specifier: 2.0.0-beta.76779 specifier: 2.0.0-beta.76
777 version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f)780 version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e)
778 drizzle-orm:781 drizzle-orm:
779 specifier: 1.0.0-rc.5-ab785fc782 specifier: 1.0.0-rc.5-ab785fc
780 version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884)783 version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884)
...@@ -1992,6 +1995,19 @@ packages:...@@ -1992,6 +1995,19 @@ packages:
1992 resolution: {integrity: sha512-WTYHwlBhImGmmMt81crxaRvNdZXlBNemFyrP5GGHj2WeDTJMBrHgHTaladTypXHXrviFFBBGROl5moijRQ4ODA==}1995 resolution: {integrity: sha512-WTYHwlBhImGmmMt81crxaRvNdZXlBNemFyrP5GGHj2WeDTJMBrHgHTaladTypXHXrviFFBBGROl5moijRQ4ODA==}
1993 engines: {node: '>=18'}1996 engines: {node: '>=18'}
19941997
1998 '@date-fns/tz@1.5.0':
1999 resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
2000
2001 '@daypicker/react@10.0.1':
2002 resolution: {integrity: sha512-lH4YQz4iMBWP8hsI1bD9Eg0T7t503IkSUR/WDGGkV5mKZvwVv+ukCkJz7yN+uVFBv7vHTK+ww7a5EvlkeFwPYQ==}
2003 engines: {node: '>=18'}
2004 peerDependencies:
2005 '@types/react': '>=16.8.0'
2006 react: '>=16.8.0'
2007 peerDependenciesMeta:
2008 '@types/react':
2009 optional: true
2010
1995 '@distilled.cloud/aws@1.0.0-rc.8':2011 '@distilled.cloud/aws@1.0.0-rc.8':
1996 resolution: {integrity: sha512-eL4tRACU4TSykk/6+ItwUTZs93r3tRlrBn36ohqaUxqYYOfmssYlt/SLCzos4nlLgCxb+1T6BAstgYC848v1EQ==}2012 resolution: {integrity: sha512-eL4tRACU4TSykk/6+ItwUTZs93r3tRlrBn36ohqaUxqYYOfmssYlt/SLCzos4nlLgCxb+1T6BAstgYC848v1EQ==}
1997 peerDependencies:2013 peerDependencies:
...@@ -6792,6 +6808,9 @@ packages:...@@ -6792,6 +6808,9 @@ packages:
6792 resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==}6808 resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==}
6793 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}6809 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
67946810
6811 date-fns@4.4.0:
6812 resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
6813
6795 dbus-next@0.10.2:6814 dbus-next@0.10.2:
6796 resolution: {integrity: sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==}6815 resolution: {integrity: sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==}
67976816
...@@ -9640,6 +9659,16 @@ packages:...@@ -9640,6 +9659,16 @@ packages:
9640 resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}9659 resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
9641 engines: {node: '>= 0.10'}9660 engines: {node: '>= 0.10'}
96429661
9662 react-day-picker@10.0.1:
9663 resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
9664 engines: {node: '>=18'}
9665 peerDependencies:
9666 '@types/react': '>=16.8.0'
9667 react: '>=16.8.0'
9668 peerDependenciesMeta:
9669 '@types/react':
9670 optional: true
9671
9643 react-devtools-core@6.1.5:9672 react-devtools-core@6.1.5:
9644 resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==}9673 resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==}
96459674
...@@ -12263,7 +12292,7 @@ snapshots:...@@ -12263,7 +12292,7 @@ snapshots:
12263 '@babel/helper-string-parser': 7.29.712292 '@babel/helper-string-parser': 7.29.7
12264 '@babel/helper-validator-identifier': 7.29.712293 '@babel/helper-validator-identifier': 7.29.7
1226512294
12266 '@base-ui/react@1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':12295 '@base-ui/react@1.5.0(@date-fns/tz@1.5.0)(@types/react@19.2.16)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12267 dependencies:12296 dependencies:
12268 '@babel/runtime': 7.29.712297 '@babel/runtime': 7.29.7
12269 '@base-ui/utils': 0.2.9(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)12298 '@base-ui/utils': 0.2.9(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
...@@ -12273,7 +12302,9 @@ snapshots:...@@ -12273,7 +12302,9 @@ snapshots:
12273 react-dom: 19.2.6(react@19.2.6)12302 react-dom: 19.2.6(react@19.2.6)
12274 use-sync-external-store: 1.6.0(react@19.2.6)12303 use-sync-external-store: 1.6.0(react@19.2.6)
12275 optionalDependencies:12304 optionalDependencies:
12305 '@date-fns/tz': 1.5.0
12276 '@types/react': 19.2.1612306 '@types/react': 19.2.16
12307 date-fns: 4.4.0
1227712308
12278 '@base-ui/utils@0.2.9(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':12309 '@base-ui/utils@0.2.9(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12279 dependencies:12310 dependencies:
...@@ -12546,6 +12577,15 @@ snapshots:...@@ -12546,6 +12577,15 @@ snapshots:
12546 '@crowecawcaw/xa11y-win32-arm64-msvc': 0.13.012577 '@crowecawcaw/xa11y-win32-arm64-msvc': 0.13.0
12547 '@crowecawcaw/xa11y-win32-x64-msvc': 0.13.012578 '@crowecawcaw/xa11y-win32-x64-msvc': 0.13.0
1254812579
12580 '@date-fns/tz@1.5.0': {}
12581
12582 '@daypicker/react@10.0.1(@types/react@19.2.16)(react@19.2.6)':
12583 dependencies:
12584 react: 19.2.6
12585 react-day-picker: 10.0.1(@types/react@19.2.16)(react@19.2.6)
12586 optionalDependencies:
12587 '@types/react': 19.2.16
12588
12549 '@distilled.cloud/aws@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':12589 '@distilled.cloud/aws@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':
12550 dependencies:12590 dependencies:
12551 '@aws-crypto/crc32': 5.2.012591 '@aws-crypto/crc32': 5.2.0
...@@ -16445,7 +16485,7 @@ snapshots:...@@ -16445,7 +16485,7 @@ snapshots:
16445 json-schema-traverse: 1.0.016485 json-schema-traverse: 1.0.0
16446 require-from-string: 2.0.216486 require-from-string: 2.0.2
1644716487
16448 alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f):16488 alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e):
16449 dependencies:16489 dependencies:
16450 '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2)16490 '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2)
16451 '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))16491 '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))
...@@ -17337,6 +17377,8 @@ snapshots:...@@ -17337,6 +17377,8 @@ snapshots:
1733717377
17338 culori@4.0.2: {}17378 culori@4.0.2: {}
1733917379
17380 date-fns@4.4.0: {}
17381
17340 dbus-next@0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4):17382 dbus-next@0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4):
17341 dependencies:17383 dependencies:
17342 '@nornagon/put': 0.0.817384 '@nornagon/put': 0.0.8
...@@ -20868,6 +20910,14 @@ snapshots:...@@ -20868,6 +20910,14 @@ snapshots:
20868 iconv-lite: 0.7.220910 iconv-lite: 0.7.2
20869 unpipe: 1.0.020911 unpipe: 1.0.0
2087020912
20913 react-day-picker@10.0.1(@types/react@19.2.16)(react@19.2.6):
20914 dependencies:
20915 '@date-fns/tz': 1.5.0
20916 date-fns: 4.4.0
20917 react: 19.2.6
20918 optionalDependencies:
20919 '@types/react': 19.2.16
20920
20871 react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6):20921 react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6):
20872 dependencies:20922 dependencies:
20873 shell-quote: 1.8.420923 shell-quote: 1.8.4