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 @@
1import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
12import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
23import { appAtomRegistry } from "../../state/atom-registry";
34import { threadArrangementOpenAtom } from "../../state/thread-order";
......@@ -471,6 +472,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
471472 [onRegenerateThreadTitle, thread],
472473 );
473474 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
475 const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false);
474476 const handleSnooze = useCallback(
475477 (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),
476478 [onSnoozeThread, thread],
......@@ -508,12 +510,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
508510 [props.snoozePresetMinute, swipeActions.secondary],
509511 );
510512 const snoozePresetActions = useMemo<MenuAction[]>(
511 () =>
512 snoozePresets.map((preset) => ({
513 () => [
514 ...snoozePresets.map((preset) => ({
513515 id: `snooze:${preset.id}`,
514516 title: preset.label,
515517 subtitle: preset.whenLabel,
516518 })),
519 { id: "snooze:custom", title: "Custom…" },
520 ],
517521 [snoozePresets],
518522 );
519523 // Pinned cards keep the full lifecycle menu; only the pin item flips to
......@@ -625,6 +629,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
625629 if (nativeEvent.event === "archive") handleArchive();
626630 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
627631 if (nativeEvent.event === "delete") handleDelete();
632 if (nativeEvent.event === "snooze:custom") {
633 setCustomSnoozeOpen(true);
634 return;
635 }
628636 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
629637 event: nativeEvent.event,
630638 displayedPresets: snoozePresets,
......@@ -1056,6 +1064,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
10561064
10571065 return (
10581066 <>
1067 {customSnoozeOpen && (
1068 <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />
1069 )}
10591070 <ThreadSwipeable
10601071 threadKey={`${thread.environmentId}:${thread.id}`}
10611072 backgroundColor={sidebarPane ? drawerColor : screenColor}
apps/mobile/src/features/usage/UsageRouteScreen.tsx+2-71
......@@ -19,9 +19,10 @@ import {
1919} from "@t3tools/shared/usageFormat";
2020import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
2121import { 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";
2323import { useSafeAreaInsets } from "react-native-safe-area-context";
2424
25import { SegmentedControl } from "../../components/SegmentedControl";
2526import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
2627import { AppText as Text } from "../../components/AppText";
2728import { cn } from "../../lib/cn";
......@@ -332,76 +333,6 @@ export function UsageRouteScreen() {
332333 );
333334}
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
405336/** Headline figure, the animated daily chart, and its legend, in one card. */
406337function ChartCard(props: {
407338 readonly merged: MergedUsage;
apps/web/package.json+1
......@@ -15,6 +15,7 @@
1515 "@base-ui/react": "^1.4.1",
1616 "@clerk/electron": "catalog:",
1717 "@clerk/react": "catalog:",
18 "@daypicker/react": "^10.0.1",
1819 "@dnd-kit/core": "^6.3.1",
1920 "@dnd-kit/modifiers": "^9.0.0",
2021 "@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 @@
1import { requestCustomSnooze } from "./CustomSnoozeDialog";
12import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
23import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
34import { useAtomValue } from "@effect/atom-react";
......@@ -439,7 +440,7 @@ function SidebarThreadTooltip({
439440function SnoozePopoverButton(props: {
440441 open: boolean;
441442 onOpenChange: (open: boolean) => void;
442 onSnooze: (preset: SnoozePreset) => void;
443 onSnooze: (preset: Pick<SnoozePreset, "snoozedUntil">) => void;
443444 timestampFormat: TimestampFormat;
444445}) {
445446 const { open, onOpenChange, onSnooze, timestampFormat } = props;
......@@ -489,6 +490,19 @@ function SnoozePopoverButton(props: {
489490 </span>
490491 </button>
491492 ))}
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>
492506 </PopoverPopup>
493507 </Popover>
494508 );
......@@ -999,7 +1013,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
9991013 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
10001014 onSettle: (threadRef: ScopedThreadRef) => void;
10011015 onUnsettle: (threadRef: ScopedThreadRef) => void;
1002 onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
1016 onSnooze: (threadRef: ScopedThreadRef, preset: Pick<SnoozePreset, "snoozedUntil">) => void;
10031017 onUnsnooze: (threadRef: ScopedThreadRef) => void;
10041018 onUnpin: (threadRef: ScopedThreadRef) => void;
10051019 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
......@@ -1333,7 +1347,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13331347 [onUnpin, threadRef],
13341348 );
13351349 const handleSnoozePreset = useCallback(
1336 (preset: SnoozePreset) => {
1350 (preset: Pick<SnoozePreset, "snoozedUntil">) => {
13371351 onSnooze(threadRef, preset);
13381352 },
13391353 [onSnooze, threadRef],
......@@ -3644,7 +3658,7 @@ export default function Sidebar() {
36443658 const performSnooze = useCallback(
36453659 async (
36463660 threadRef: ScopedThreadRef,
3647 preset: SnoozePreset,
3661 preset: Pick<SnoozePreset, "snoozedUntil">,
36483662 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
36493663 ) => {
36503664 const threadKey = scopedThreadKey(threadRef);
......@@ -3678,7 +3692,7 @@ export default function Sidebar() {
36783692 const attemptSnooze = useCallback(
36793693 (
36803694 threadRef: ScopedThreadRef,
3681 preset: SnoozePreset,
3695 preset: Pick<SnoozePreset, "snoozedUntil">,
36823696 opts: { coSnoozingKeys?: ReadonlySet<string> } = {},
36833697 ) => {
36843698 void (async () => {
......@@ -3774,10 +3788,13 @@ export default function Sidebar() {
37743788 {
37753789 id: "snooze",
37763790 label: `Snooze (${count})`,
3777 children: snoozePresets.map((preset) => ({
3778 id: `snooze:${preset.id}`,
3779 label: `${preset.label} (${preset.whenLabel})`,
3780 })),
3791 children: [
3792 ...snoozePresets.map((preset) => ({
3793 id: `snooze:${preset.id}`,
3794 label: `${preset.label} (${preset.whenLabel})`,
3795 })),
3796 { id: "snooze:custom", label: "Custom…", separatorBefore: true },
3797 ],
37813798 },
37823799 ]
37833800 : []),
......@@ -3790,9 +3807,10 @@ export default function Sidebar() {
37903807 );
37913808 if (clicked._tag === "Failure") return;
37923809 if (clicked.value?.startsWith("snooze:")) {
3793 const preset = snoozePresets.find(
3794 (candidate) => `snooze:${candidate.id}` === clicked.value,
3795 );
3810 const preset =
3811 clicked.value === "snooze:custom"
3812 ? await requestCustomSnooze()
3813 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
37963814 if (preset) {
37973815 // Post-snooze navigation must skip threads snoozing in this same
37983816 // batch — they are all leaving the card block together.
......@@ -4019,9 +4037,10 @@ export default function Sidebar() {
40194037 );
40204038 if (clicked._tag === "Failure") return;
40214039 if (clicked.value?.startsWith("snooze:")) {
4022 const preset = snoozePresets.find(
4023 (candidate) => `snooze:${candidate.id}` === clicked.value,
4024 );
4040 const preset =
4041 clicked.value === "snooze:custom"
4042 ? await requestCustomSnooze()
4043 : snoozePresets.find((candidate) => `snooze:${candidate.id}` === clicked.value);
40254044 if (preset) attemptSnooze(threadRef, preset);
40264045 return;
40274046 }
apps/web/src/components/threadActionMenu.logic.test.ts+1-1
......@@ -67,7 +67,7 @@ describe("buildThreadActionMenuItems", () => {
6767 (item) => item.id === "snooze",
6868 );
6969 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"]);
7171 });
7272
7373 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(
8888 label: "Snooze",
8989 icon: "clock",
9090 disabled: !state.canSnoozeNow,
91 children: state.snoozePresets.map((preset) => ({
92 id: `snooze:${preset.id}` as const,
93 label: `${preset.label} (${preset.whenLabel})`,
94 })),
91 children: [
92 ...state.snoozePresets.map((preset) => ({
93 id: `snooze:${preset.id}` as const,
94 label: `${preset.label} (${preset.whenLabel})`,
95 })),
96 { id: "snooze:custom" as const, label: "Custom…", separatorBefore: true },
97 ],
9598 },
9699 ]
97100 : []),
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 @@
1import { requestCustomSnooze } from "../components/CustomSnoozeDialog";
12import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
23import {
34 type AtomCommandResult,
......@@ -152,7 +153,10 @@ export function useThreadActionMenu(input: {
152153 if (clicked._tag === "Failure" || clicked.value === null) return;
153154 const action: ThreadActionMenuId = clicked.value;
154155 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);
156160 if (!preset) return;
157161 const result = await snoozeThread(threadRef, preset.snoozedUntil);
158162 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 ".
1818import { resolveServerBackedAppDisplayName } from "../branding.logic";
1919import { AppSidebarLayout } from "../components/AppSidebarLayout";
2020import { CommandPalette } from "../components/CommandPalette";
21import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog";
2122import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
2223import { FirstRunGate } from "../components/onboarding/FirstRunGate";
2324import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
......@@ -171,6 +172,7 @@ function RootRouteView() {
171172 <EnvironmentThemeSync />
172173 <GlassAppearanceSync />
173174 <FontAppearanceSync />
175 <CustomSnoozeDialogHost />
174176 <CommandPalette>
175177 <AppSidebarLayout>
176178 <Outlet />
......@@ -222,6 +224,7 @@ function RootRouteView() {
222224 <SnapShotCoordinator />
223225 <ThreadNotificationCoordinator />
224226 <ConfirmDialogHost />
227 <CustomSnoozeDialogHost />
225228 <SlowRpcRequestToastCoordinator />
226229 <ProjectCloneToastCoordinator />
227230 <HostedStaticEnvironmentBootstrap />
docs/user/thread-sidebar.md+7
......@@ -125,3 +125,10 @@ On web and desktop, use **Agents** to follow work delegated to subagents.
125125Expand a tool call in the conversation to see its full command and output.
126126Summaries shorten shell wrappers and can still describe the latest call after it
127127finishes; 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
274274 if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`;
275275 return `${Math.ceil(remainingMs / DAY_MS)}d`;
276276}
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:
569569 dependencies:
570570 '@base-ui/react':
571571 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)
573573 '@clerk/electron':
574574 specifier: 0.0.42
575575 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)
576576 '@clerk/react':
577577 specifier: 6.15.2
578578 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)
579582 '@dnd-kit/core':
580583 specifier: ^6.3.1
581584 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
......@@ -774,7 +777,7 @@ importers:
774777 version: link:../../packages/shared
775778 alchemy:
776779 specifier: 2.0.0-beta.76
777 version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f)
780 version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e)
778781 drizzle-orm:
779782 specifier: 1.0.0-rc.5-ab785fc
780783 version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884)
......@@ -1992,6 +1995,19 @@ packages:
19921995 resolution: {integrity: sha512-WTYHwlBhImGmmMt81crxaRvNdZXlBNemFyrP5GGHj2WeDTJMBrHgHTaladTypXHXrviFFBBGROl5moijRQ4ODA==}
19931996 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
19952011 '@distilled.cloud/aws@1.0.0-rc.8':
19962012 resolution: {integrity: sha512-eL4tRACU4TSykk/6+ItwUTZs93r3tRlrBn36ohqaUxqYYOfmssYlt/SLCzos4nlLgCxb+1T6BAstgYC848v1EQ==}
19972013 peerDependencies:
......@@ -6792,6 +6808,9 @@ packages:
67926808 resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==}
67936809 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
67956814 dbus-next@0.10.2:
67966815 resolution: {integrity: sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==}
67976816
......@@ -9640,6 +9659,16 @@ packages:
96409659 resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
96419660 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
96439672 react-devtools-core@6.1.5:
96449673 resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==}
96459674
......@@ -12263,7 +12292,7 @@ snapshots:
1226312292 '@babel/helper-string-parser': 7.29.7
1226412293 '@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)':
1226712296 dependencies:
1226812297 '@babel/runtime': 7.29.7
1226912298 '@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:
1227312302 react-dom: 19.2.6(react@19.2.6)
1227412303 use-sync-external-store: 1.6.0(react@19.2.6)
1227512304 optionalDependencies:
12305 '@date-fns/tz': 1.5.0
1227612306 '@types/react': 19.2.16
12307 date-fns: 4.4.0
1227712308
1227812309 '@base-ui/utils@0.2.9(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
1227912310 dependencies:
......@@ -12546,6 +12577,15 @@ snapshots:
1254612577 '@crowecawcaw/xa11y-win32-arm64-msvc': 0.13.0
1254712578 '@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
1254912589 '@distilled.cloud/aws@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))':
1255012590 dependencies:
1255112591 '@aws-crypto/crc32': 5.2.0
......@@ -16445,7 +16485,7 @@ snapshots:
1644516485 json-schema-traverse: 1.0.0
1644616486 require-from-string: 2.0.2
1644716487
16448 alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f):
16488 alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e):
1644916489 dependencies:
1645016490 '@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)
1645116491 '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))
......@@ -17337,6 +17377,8 @@ snapshots:
1733717377
1733817378 culori@4.0.2: {}
1733917379
17380 date-fns@4.4.0: {}
17381
1734017382 dbus-next@0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4):
1734117383 dependencies:
1734217384 '@nornagon/put': 0.0.8
......@@ -20868,6 +20910,14 @@ snapshots:
2086820910 iconv-lite: 0.7.2
2086920911 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
2087120921 react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6):
2087220922 dependencies:
2087320923 shell-quote: 1.8.4