| author | |
| committer | |
| log | 3be02ae5791480b8c8bf3a13020a932378eca1ba |
| tree | 2d160591f6361063659c08c9fa282393daddeb7d |
| parent | 014016a62344ab03dbccdb73c3c1861bc4f7e349 |
| signature | Signed by PGP key B5690EEEBB952194 |
17 files changed, 1109 insertions(+), 98 deletions(-)
apps/mobile/src/components/SegmentedControl.tsx created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import { Platform, Pressable, View } from "react-native"; | |
| 2 | import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-reanimated"; | |
| 3 | import { AppText as Text } from "./AppText"; | |
| 4 | import { cn } from "../lib/cn"; | |
| 5 | ||
| 6 | export 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 @@ |
| 1 | import { | |
| 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"; | |
| 13 | import { | |
| 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"; | |
| 26 | import { | |
| 27 | localSnoozeDate, | |
| 28 | localSnoozeTime, | |
| 29 | resolveCustomSnooze, | |
| 30 | type CustomSnoozeInput, | |
| 31 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 32 | import { useState } from "react"; | |
| 33 | import { Modal, Pressable, ScrollView, View } from "react-native"; | |
| 34 | import { AppText } from "../../components/AppText"; | |
| 35 | import { SegmentedControl } from "../../components/SegmentedControl"; | |
| 36 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 37 | ||
| 38 | const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); | |
| 39 | const modes = [ | |
| 40 | { value: "date", label: "Date and time" }, | |
| 41 | { value: "duration", label: "Duration" }, | |
| 42 | ] as const; | |
| 43 | const units = [ | |
| 44 | { value: "minutes", label: "Minutes" }, | |
| 45 | { value: "hours", label: "Hours" }, | |
| 46 | { value: "days", label: "Days" }, | |
| 47 | ] as const; | |
| 48 | ||
| 49 | export 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 | ||
| 206 | function 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 | ||
| 221 | function 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 @@ |
| 1 | import { DateTimePicker } from "@expo/ui/community/datetime-picker"; | |
| 2 | import { | |
| 3 | localSnoozeDate, | |
| 4 | localSnoozeTime, | |
| 5 | resolveCustomSnooze, | |
| 6 | type CustomSnoozeInput, | |
| 7 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 8 | import { useState } from "react"; | |
| 9 | import { | |
| 10 | KeyboardAvoidingView, | |
| 11 | Modal, | |
| 12 | Platform, | |
| 13 | Pressable, | |
| 14 | ScrollView, | |
| 15 | TextInput, | |
| 16 | View, | |
| 17 | } from "react-native"; | |
| 18 | import { AppText } from "../../components/AppText"; | |
| 19 | import { SegmentedControl } from "../../components/SegmentedControl"; | |
| 20 | ||
| 21 | export 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 | import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; | |
| 1 | 2 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 2 | 3 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 3 | 4 | import { threadArrangementOpenAtom } from "../../state/thread-order"; |
| ... | ... | @@ -471,6 +472,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 471 | 472 | [onRegenerateThreadTitle, thread], |
| 472 | 473 | ); |
| 473 | 474 | const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); |
| 475 | const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false); | |
| 474 | 476 | const handleSnooze = useCallback( |
| 475 | 477 | (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), |
| 476 | 478 | [onSnoozeThread, thread], |
| ... | ... | @@ -508,12 +510,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 508 | 510 | [props.snoozePresetMinute, swipeActions.secondary], |
| 509 | 511 | ); |
| 510 | 512 | const snoozePresetActions = useMemo<MenuAction[]>( |
| 511 | () => | |
| 512 | snoozePresets.map((preset) => ({ | |
| 513 | () => [ | |
| 514 | ...snoozePresets.map((preset) => ({ | |
| 513 | 515 | id: `snooze:${preset.id}`, |
| 514 | 516 | title: preset.label, |
| 515 | 517 | subtitle: preset.whenLabel, |
| 516 | 518 | })), |
| 519 | { id: "snooze:custom", title: "Custom…" }, | |
| 520 | ], | |
| 517 | 521 | [snoozePresets], |
| 518 | 522 | ); |
| 519 | 523 | // 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 | 629 | if (nativeEvent.event === "archive") handleArchive(); |
| 626 | 630 | if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); |
| 627 | 631 | if (nativeEvent.event === "delete") handleDelete(); |
| 632 | if (nativeEvent.event === "snooze:custom") { | |
| 633 | setCustomSnoozeOpen(true); | |
| 634 | return; | |
| 635 | } | |
| 628 | 636 | const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ |
| 629 | 637 | event: nativeEvent.event, |
| 630 | 638 | displayedPresets: snoozePresets, |
| ... | ... | @@ -1056,6 +1064,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1056 | 1064 | |
| 1057 | 1065 | return ( |
| 1058 | 1066 | <> |
| 1067 | {customSnoozeOpen && ( | |
| 1068 | <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> | |
| 1069 | )} | |
| 1059 | 1070 | <ThreadSwipeable |
| 1060 | 1071 | threadKey={`${thread.environmentId}:${thread.id}`} |
| 1061 | 1072 | backgroundColor={sidebarPane ? drawerColor : screenColor} |
apps/mobile/src/features/usage/UsageRouteScreen.tsx+2-71| ... | ... | @@ -19,9 +19,10 @@ import { |
| 19 | 19 | } from "@t3tools/shared/usageFormat"; |
| 20 | 20 | import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 21 | 21 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| 22 | import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; | |
| 22 | import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; | |
| 23 | 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | 24 | |
| 25 | import { SegmentedControl } from "../../components/SegmentedControl"; | |
| 25 | 26 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 26 | 27 | import { AppText as Text } from "../../components/AppText"; |
| 27 | 28 | import { cn } from "../../lib/cn"; |
| ... | ... | @@ -332,76 +333,6 @@ export function UsageRouteScreen() { |
| 332 | 333 | ); |
| 333 | 334 | } |
| 334 | 335 | |
| 335 | function 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 | 336 | /** Headline figure, the animated daily chart, and its legend, in one card. */ |
| 406 | 337 | function ChartCard(props: { |
| 407 | 338 | readonly merged: MergedUsage; |
apps/web/package.json+1| ... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 | "@base-ui/react": "^1.4.1", |
| 16 | 16 | "@clerk/electron": "catalog:", |
| 17 | 17 | "@clerk/react": "catalog:", |
| 18 | "@daypicker/react": "^10.0.1", | |
| 18 | 19 | "@dnd-kit/core": "^6.3.1", |
| 19 | 20 | "@dnd-kit/modifiers": "^9.0.0", |
| 20 | 21 | "@dnd-kit/sortable": "^10.0.0", |
apps/web/src/components/CustomSnoozeDialog.tsx created+245| ... | ... | @@ -0,0 +1,245 @@ |
| 1 | import { useEffect, useId, useState } from "react"; | |
| 2 | import { Tabs } from "@base-ui/react/tabs"; | |
| 3 | import { create } from "zustand"; | |
| 4 | import { | |
| 5 | localSnoozeDate, | |
| 6 | localSnoozeTime, | |
| 7 | resolveCustomSnooze, | |
| 8 | type CustomSnoozeInput, | |
| 9 | } from "@t3tools/client-runtime/state/thread-settled"; | |
| 10 | import { Button } from "./ui/button"; | |
| 11 | import { CalendarIcon } from "lucide-react"; | |
| 12 | import { Calendar } from "./ui/calendar"; | |
| 13 | import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; | |
| 14 | import { Input } from "./ui/input"; | |
| 15 | import { Label } from "./ui/label"; | |
| 16 | import { toggleVariants } from "./ui/toggle"; | |
| 17 | import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select"; | |
| 18 | import { | |
| 19 | NumberField, | |
| 20 | NumberFieldGroup, | |
| 21 | NumberFieldInput, | |
| 22 | NumberFieldDecrement, | |
| 23 | NumberFieldIncrement, | |
| 24 | } from "./ui/number-field"; | |
| 25 | import { | |
| 26 | Dialog, | |
| 27 | DialogPopup, | |
| 28 | DialogHeader, | |
| 29 | DialogTitle, | |
| 30 | DialogDescription, | |
| 31 | DialogPanel, | |
| 32 | DialogFooter, | |
| 33 | } from "./ui/dialog"; | |
| 34 | ||
| 35 | type SnoozeChoice = { readonly snoozedUntil: string }; | |
| 36 | type Request = { readonly resolve: (choice: SnoozeChoice | null) => void }; | |
| 37 | const useRequest = create<{ request: Request | null }>(() => ({ request: null })); | |
| 38 | ||
| 39 | export function requestCustomSnooze(): Promise<SnoozeChoice | null> { | |
| 40 | useRequest.getState().request?.resolve(null); | |
| 41 | return new Promise((resolve) => useRequest.setState({ request: { resolve } })); | |
| 42 | } | |
| 43 | ||
| 44 | function finish(choice: SnoozeChoice | null) { | |
| 45 | const request = useRequest.getState().request; | |
| 46 | useRequest.setState({ request: null }); | |
| 47 | request?.resolve(choice); | |
| 48 | } | |
| 49 | ||
| 50 | export function CustomSnoozeDialogHost() { | |
| 51 | const request = useRequest((state) => state.request); | |
| 52 | useEffect(() => () => finish(null), []); | |
| 53 | return request ? <CustomSnoozeDialog /> : null; | |
| 54 | } | |
| 55 | ||
| 56 | function 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 | import { requestCustomSnooze } from "./CustomSnoozeDialog"; | |
| 1 | 2 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 2 | 3 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; |
| 3 | 4 | import { useAtomValue } from "@effect/atom-react"; |
| ... | ... | @@ -439,7 +440,7 @@ function SidebarThreadTooltip({ |
| 439 | 440 | function SnoozePopoverButton(props: { |
| 440 | 441 | open: boolean; |
| 441 | 442 | onOpenChange: (open: boolean) => void; |
| 442 | onSnooze: (preset: SnoozePreset) => void; | |
| 443 | onSnooze: (preset: Pick<SnoozePreset, "snoozedUntil">) => void; | |
| 443 | 444 | timestampFormat: TimestampFormat; |
| 444 | 445 | }) { |
| 445 | 446 | const { open, onOpenChange, onSnooze, timestampFormat } = props; |
| ... | ... | @@ -489,6 +490,19 @@ function SnoozePopoverButton(props: { |
| 489 | 490 | </span> |
| 490 | 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 | 506 | </PopoverPopup> |
| 493 | 507 | </Popover> |
| 494 | 508 | ); |
| ... | ... | @@ -999,7 +1013,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 999 | 1013 | onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; |
| 1000 | 1014 | onSettle: (threadRef: ScopedThreadRef) => void; |
| 1001 | 1015 | onUnsettle: (threadRef: ScopedThreadRef) => void; |
| 1002 | onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; | |
| 1016 | onSnooze: (threadRef: ScopedThreadRef, preset: Pick<SnoozePreset, "snoozedUntil">) => void; | |
| 1003 | 1017 | onUnsnooze: (threadRef: ScopedThreadRef) => void; |
| 1004 | 1018 | onUnpin: (threadRef: ScopedThreadRef) => void; |
| 1005 | 1019 | onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; |
| ... | ... | @@ -1333,7 +1347,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1333 | 1347 | [onUnpin, threadRef], |
| 1334 | 1348 | ); |
| 1335 | 1349 | const handleSnoozePreset = useCallback( |
| 1336 | (preset: SnoozePreset) => { | |
| 1350 | (preset: Pick<SnoozePreset, "snoozedUntil">) => { | |
| 1337 | 1351 | onSnooze(threadRef, preset); |
| 1338 | 1352 | }, |
| 1339 | 1353 | [onSnooze, threadRef], |
| ... | ... | @@ -3644,7 +3658,7 @@ export default function Sidebar() { |
| 3644 | 3658 | const performSnooze = useCallback( |
| 3645 | 3659 | async ( |
| 3646 | 3660 | threadRef: ScopedThreadRef, |
| 3647 | preset: SnoozePreset, | |
| 3661 | preset: Pick<SnoozePreset, "snoozedUntil">, | |
| 3648 | 3662 | opts: { coSnoozingKeys?: ReadonlySet<string> } = {}, |
| 3649 | 3663 | ) => { |
| 3650 | 3664 | const threadKey = scopedThreadKey(threadRef); |
| ... | ... | @@ -3678,7 +3692,7 @@ export default function Sidebar() { |
| 3678 | 3692 | const attemptSnooze = useCallback( |
| 3679 | 3693 | ( |
| 3680 | 3694 | threadRef: ScopedThreadRef, |
| 3681 | preset: SnoozePreset, | |
| 3695 | preset: Pick<SnoozePreset, "snoozedUntil">, | |
| 3682 | 3696 | opts: { coSnoozingKeys?: ReadonlySet<string> } = {}, |
| 3683 | 3697 | ) => { |
| 3684 | 3698 | void (async () => { |
| ... | ... | @@ -3774,10 +3788,13 @@ export default function Sidebar() { |
| 3774 | 3788 | { |
| 3775 | 3789 | id: "snooze", |
| 3776 | 3790 | 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 | ], | |
| 3781 | 3798 | }, |
| 3782 | 3799 | ] |
| 3783 | 3800 | : []), |
| ... | ... | @@ -3790,9 +3807,10 @@ export default function Sidebar() { |
| 3790 | 3807 | ); |
| 3791 | 3808 | if (clicked._tag === "Failure") return; |
| 3792 | 3809 | 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); | |
| 3796 | 3814 | if (preset) { |
| 3797 | 3815 | // Post-snooze navigation must skip threads snoozing in this same |
| 3798 | 3816 | // batch — they are all leaving the card block together. |
| ... | ... | @@ -4019,9 +4037,10 @@ export default function Sidebar() { |
| 4019 | 4037 | ); |
| 4020 | 4038 | if (clicked._tag === "Failure") return; |
| 4021 | 4039 | 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); | |
| 4025 | 4044 | if (preset) attemptSnooze(threadRef, preset); |
| 4026 | 4045 | return; |
| 4027 | 4046 | } |
apps/web/src/components/threadActionMenu.logic.test.ts+1-1| ... | ... | @@ -67,7 +67,7 @@ describe("buildThreadActionMenuItems", () => { |
| 67 | 67 | (item) => item.id === "snooze", |
| 68 | 68 | ); |
| 69 | 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 | }); |
| 72 | 72 | |
| 73 | 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 | 88 | label: "Snooze", |
| 89 | 89 | icon: "clock", |
| 90 | 90 | 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 | ], | |
| 95 | 98 | }, |
| 96 | 99 | ] |
| 97 | 100 | : []), |
apps/web/src/components/ui/calendar.tsx created+112| ... | ... | @@ -0,0 +1,112 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import { DayPicker } from "@daypicker/react"; | |
| 4 | import { ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon } from "lucide-react"; | |
| 5 | import type * as React from "react"; | |
| 6 | import { cn } from "~/lib/utils"; | |
| 7 | ||
| 8 | const 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 | ||
| 11 | const 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 | ||
| 44 | export 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 | import { requestCustomSnooze } from "../components/CustomSnoozeDialog"; | |
| 1 | 2 | import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 2 | 3 | import { |
| 3 | 4 | type AtomCommandResult, |
| ... | ... | @@ -152,7 +153,10 @@ export function useThreadActionMenu(input: { |
| 152 | 153 | if (clicked._tag === "Failure" || clicked.value === null) return; |
| 153 | 154 | const action: ThreadActionMenuId = clicked.value; |
| 154 | 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 | 160 | if (!preset) return; |
| 157 | 161 | const result = await snoozeThread(threadRef, preset.snoozedUntil); |
| 158 | 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 | 18 | import { resolveServerBackedAppDisplayName } from "../branding.logic"; |
| 19 | 19 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 20 | 20 | import { CommandPalette } from "../components/CommandPalette"; |
| 21 | import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; | |
| 21 | 22 | import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; |
| 22 | 23 | import { FirstRunGate } from "../components/onboarding/FirstRunGate"; |
| 23 | 24 | import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; |
| ... | ... | @@ -171,6 +172,7 @@ function RootRouteView() { |
| 171 | 172 | <EnvironmentThemeSync /> |
| 172 | 173 | <GlassAppearanceSync /> |
| 173 | 174 | <FontAppearanceSync /> |
| 175 | <CustomSnoozeDialogHost /> | |
| 174 | 176 | <CommandPalette> |
| 175 | 177 | <AppSidebarLayout> |
| 176 | 178 | <Outlet /> |
| ... | ... | @@ -222,6 +224,7 @@ function RootRouteView() { |
| 222 | 224 | <SnapShotCoordinator /> |
| 223 | 225 | <ThreadNotificationCoordinator /> |
| 224 | 226 | <ConfirmDialogHost /> |
| 227 | <CustomSnoozeDialogHost /> | |
| 225 | 228 | <SlowRpcRequestToastCoordinator /> |
| 226 | 229 | <ProjectCloneToastCoordinator /> |
| 227 | 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 | 125 | Expand a tool call in the conversation to see its full command and output. |
| 126 | 126 | Summaries shorten shell wrappers and can still describe the latest call after it |
| 127 | 127 | finishes; the call's own result shows its status. |
| 128 | ||
| 129 | ## Snooze until later | |
| 130 | ||
| 131 | Choose **Snooze → Custom…** from a thread's menu to pick a date and time in your | |
| 132 | local time zone, or a duration in minutes, hours, or days. Durations start when | |
| 133 | you confirm; one day means 24 hours. On web and desktop, you can also snooze | |
| 134 | several 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. | |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | import { localSnoozeDate, localSnoozeTime, resolveCustomSnooze } from "./threadSettled.ts"; | |
| 4 | ||
| 5 | const now = new Date(2026, 8, 14, 14, 30); | |
| 6 | ||
| 7 | afterEach(() => vi.unstubAllEnvs()); | |
| 8 | ||
| 9 | describe("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 | 274 | if (remainingMs < DAY_MS) return `${Math.ceil(remainingMs / HOUR_MS)}h`; |
| 275 | 275 | return `${Math.ceil(remainingMs / DAY_MS)}d`; |
| 276 | 276 | } |
| 277 | ||
| 278 | export 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. */ | |
| 287 | export 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 | ||
| 305 | export 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 | ||
| 309 | export 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 | 569 | dependencies: |
| 570 | 570 | '@base-ui/react': |
| 571 | 571 | 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 | 573 | '@clerk/electron': |
| 574 | 574 | specifier: 0.0.42 |
| 575 | 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 | 576 | '@clerk/react': |
| 577 | 577 | specifier: 6.15.2 |
| 578 | 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 | 582 | '@dnd-kit/core': |
| 580 | 583 | specifier: ^6.3.1 |
| 581 | 584 | version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| ... | ... | @@ -774,7 +777,7 @@ importers: |
| 774 | 777 | version: link:../../packages/shared |
| 775 | 778 | alchemy: |
| 776 | 779 | specifier: 2.0.0-beta.76 |
| 777 | version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) | |
| 780 | version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) | |
| 778 | 781 | drizzle-orm: |
| 779 | 782 | specifier: 1.0.0-rc.5-ab785fc |
| 780 | 783 | version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) |
| ... | ... | @@ -1992,6 +1995,19 @@ packages: |
| 1992 | 1995 | resolution: {integrity: sha512-WTYHwlBhImGmmMt81crxaRvNdZXlBNemFyrP5GGHj2WeDTJMBrHgHTaladTypXHXrviFFBBGROl5moijRQ4ODA==} |
| 1993 | 1996 | engines: {node: '>=18'} |
| 1994 | 1997 | |
| 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 | 2011 | '@distilled.cloud/aws@1.0.0-rc.8': |
| 1996 | 2012 | resolution: {integrity: sha512-eL4tRACU4TSykk/6+ItwUTZs93r3tRlrBn36ohqaUxqYYOfmssYlt/SLCzos4nlLgCxb+1T6BAstgYC848v1EQ==} |
| 1997 | 2013 | peerDependencies: |
| ... | ... | @@ -6792,6 +6808,9 @@ packages: |
| 6792 | 6808 | resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} |
| 6793 | 6809 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} |
| 6794 | 6810 | |
| 6811 | date-fns@4.4.0: | |
| 6812 | resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} | |
| 6813 | ||
| 6795 | 6814 | dbus-next@0.10.2: |
| 6796 | 6815 | resolution: {integrity: sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==} |
| 6797 | 6816 | |
| ... | ... | @@ -9640,6 +9659,16 @@ packages: |
| 9640 | 9659 | resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} |
| 9641 | 9660 | engines: {node: '>= 0.10'} |
| 9642 | 9661 | |
| 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 | 9672 | react-devtools-core@6.1.5: |
| 9644 | 9673 | resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} |
| 9645 | 9674 | |
| ... | ... | @@ -12263,7 +12292,7 @@ snapshots: |
| 12263 | 12292 | '@babel/helper-string-parser': 7.29.7 |
| 12264 | 12293 | '@babel/helper-validator-identifier': 7.29.7 |
| 12265 | 12294 | |
| 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 | 12296 | dependencies: |
| 12268 | 12297 | '@babel/runtime': 7.29.7 |
| 12269 | 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 | 12302 | react-dom: 19.2.6(react@19.2.6) |
| 12274 | 12303 | use-sync-external-store: 1.6.0(react@19.2.6) |
| 12275 | 12304 | optionalDependencies: |
| 12305 | '@date-fns/tz': 1.5.0 | |
| 12276 | 12306 | '@types/react': 19.2.16 |
| 12307 | date-fns: 4.4.0 | |
| 12277 | 12308 | |
| 12278 | 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 | 12310 | dependencies: |
| ... | ... | @@ -12546,6 +12577,15 @@ snapshots: |
| 12546 | 12577 | '@crowecawcaw/xa11y-win32-arm64-msvc': 0.13.0 |
| 12547 | 12578 | '@crowecawcaw/xa11y-win32-x64-msvc': 0.13.0 |
| 12548 | 12579 | |
| 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 | 12589 | '@distilled.cloud/aws@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': |
| 12550 | 12590 | dependencies: |
| 12551 | 12591 | '@aws-crypto/crc32': 5.2.0 |
| ... | ... | @@ -16445,7 +16485,7 @@ snapshots: |
| 16445 | 16485 | json-schema-traverse: 1.0.0 |
| 16446 | 16486 | require-from-string: 2.0.2 |
| 16447 | 16487 | |
| 16448 | alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): | |
| 16488 | alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): | |
| 16449 | 16489 | dependencies: |
| 16450 | 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 | 16491 | '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) |
| ... | ... | @@ -17337,6 +17377,8 @@ snapshots: |
| 17337 | 17377 | |
| 17338 | 17378 | culori@4.0.2: {} |
| 17339 | 17379 | |
| 17380 | date-fns@4.4.0: {} | |
| 17381 | ||
| 17340 | 17382 | dbus-next@0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4): |
| 17341 | 17383 | dependencies: |
| 17342 | 17384 | '@nornagon/put': 0.0.8 |
| ... | ... | @@ -20868,6 +20910,14 @@ snapshots: |
| 20868 | 20910 | iconv-lite: 0.7.2 |
| 20869 | 20911 | unpipe: 1.0.0 |
| 20870 | 20912 | |
| 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 | 20921 | react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): |
| 20872 | 20922 | dependencies: |
| 20873 | 20923 | shell-quote: 1.8.4 |