# Files changed · PR \#23884 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23884/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(web\): customize snooze presets](https://git.cafe/pingdotgg/t3code/pull/23884.md)

Version: 19

Head commit: 79d9e0fb2f22ef1e95dc98161381013c0be4eb69

Base commit: 071239804e375078dd3fae3b14704b4def57e048

Comparison base: 071239804e375078dd3fae3b14704b4def57e048

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx

Status: modified; +2; −0

```diff
@@ -1,3 +1,4 @@
+import { SnoozeSettingsSection } from "./SnoozeSettingsSection";
 import { Button } from "../../components/ui/Button";
 import { AuthSettingsWriteScope } from "@t3tools/contracts";
 import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
@@ -46,6 +47,7 @@
           contentContainerClassName="gap-6 px-5 pt-4"
           contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
         >
+          <SnoozeSettingsSection />
           <AutoSettleSettingsRows />
           <BetaSettingsSection />
           <LegacySettingsSection />
```

## apps/mobile/src/features/settings/SnoozeSettingsSection.tsx

Status: added; +220; −0

```diff
@@ -1,0 +1,220 @@
+import { useAtomSet, useAtomValue } from "@effect/atom-react";
+import { DateTimePicker } from "@expo/ui/community/datetime-picker";
+import {
+  DEFAULT_SNOOZE_PRESETS,
+  MAX_SNOOZE_HOURS,
+  SNOOZE_ABSOLUTE_DAYS,
+  type SnoozePresetSetting,
+} from "@t3tools/contracts/settings";
+import { AsyncResult } from "effect/reactivity";
+import { useState } from "react";
+import { Platform, Pressable, View } from "react-native";
+
+import { AppText as Text } from "../../components/ui/AppText";
+import { NumberField } from "../../components/ui/NumberField";
+import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
+import { SettingsSection } from "./components/SettingsSection";
+import { Menu } from "../../components/ui/Menu";
+import { Button } from "../../components/ui/Button";
+import { SymbolView } from "../../components/ui/AppSymbol";
+
+function SnoozeTimeRow(props: {
+  readonly label: string;
+  readonly accessibilityLabel: string;
+  readonly value: string;
+  readonly disabled: boolean;
+  readonly onValueChange: (value: string) => void;
+}) {
+  const [open, setOpen] = useState(false);
+  const [hours, minutes] = props.value.split(":").map(Number);
+  const date = new Date(2000, 0, 1, hours, minutes);
+  return (
+    <View>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel={props.accessibilityLabel}
+        accessibilityState={{ disabled: props.disabled }}
+        disabled={props.disabled}
+        onPress={() => setOpen((value) => !value)}
+        className="min-h-10 flex-row items-center gap-2"
+      >
+        <Text textRole="footnote" className="text-foreground-muted">
+          {props.label}
+        </Text>
+        <Text className="text-base text-foreground-muted">
+          {date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
+        </Text>
+      </Pressable>
+      {open && !props.disabled ? (
+        <DateTimePicker
+          value={date}
+          mode="time"
+          display={Platform.OS === "ios" ? "spinner" : "default"}
+          onDismiss={() => setOpen(false)}
+          onValueChange={(_, selected) => {
+            props.onValueChange(
+              `${String(selected.getHours()).padStart(2, "0")}:${String(selected.getMinutes()).padStart(2, "0")}`,
+            );
+            if (Platform.OS === "android") setOpen(false);
+          }}
+        />
+      ) : null}
+    </View>
+  );
+}
+
+function SnoozeSlotEditor(props: {
+  readonly slot: SnoozePresetSetting;
+  readonly index: number;
+  readonly disabled: boolean;
+  readonly onChange: (slot: SnoozePresetSetting) => void;
+}) {
+  const { slot, disabled, onChange } = props;
+  const label = `Snooze slot ${props.index + 1}`;
+  return (
+    <View
+      className="flex-row items-center justify-between gap-3 border-t border-border-subtle px-4 py-2"
+      pointerEvents={disabled ? "none" : "auto"}
+      accessibilityState={{ disabled }}
+    >
+      <Menu
+        actions={[
+          { id: "relative", title: "Relative", state: slot.mode === "relative" ? "on" : "off" },
+          { id: "absolute", title: "Absolute", state: slot.mode === "absolute" ? "on" : "off" },
+        ]}
+        onPressAction={({ nativeEvent }) => {
+          const mode = nativeEvent.event;
+          if (disabled || mode === slot.mode) return;
+          if (mode === "relative") onChange({ mode, hours: 1 });
+          if (mode === "absolute") onChange({ mode, day: "tomorrow", time: "09:00" });
+        }}
+      >
+        <Pressable
+          accessibilityRole="button"
+          accessibilityLabel={`${label} mode`}
+          disabled={disabled}
+          className="min-h-11 flex-row items-center gap-2"
+        >
+          <Text textRole="body" className="text-foreground">
+            {slot.mode === "relative" ? "Relative" : "Absolute"}
+          </Text>
+          <SymbolView name="chevron.down" size={10} tintColorClassName="accent-chevron" />
+        </Pressable>
+      </Menu>
+      <View className="gap-1">
+        {slot.mode === "relative" ? (
+          <View className="flex-row items-center justify-end gap-2">
+            <NumberField
+              label={`${label} hours`}
+              min={1}
+              max={MAX_SNOOZE_HOURS}
+              value={slot.hours}
+              disabled={disabled}
+              onValueChange={(hours) => onChange({ ...slot, hours })}
+            />
+            <Text textRole="footnote" className="w-10 text-foreground-muted">
+              hours
+            </Text>
+          </View>
+        ) : (
+          <>
+            <Menu
+              actions={SNOOZE_ABSOLUTE_DAYS.map((day) => ({
+                id: day,
+                title: day.charAt(0).toUpperCase() + day.slice(1),
+                state: day === slot.day ? "on" : "off",
+              }))}
+              onPressAction={({ nativeEvent }) => {
+                const day = SNOOZE_ABSOLUTE_DAYS.find(
+                  (candidate) => candidate === nativeEvent.event,
+                );
+                if (!disabled && day) onChange({ ...slot, day });
+              }}
+            >
+              <Pressable
+                accessibilityRole="button"
+                accessibilityLabel={`${label} day: ${slot.day}`}
+                disabled={disabled}
+                className="min-h-10 flex-row items-center justify-end gap-2"
+              >
+                <Text textRole="body" className="text-foreground">
+                  {slot.day.charAt(0).toUpperCase() + slot.day.slice(1)}
+                </Text>
+                <SymbolView name="chevron.down" size={10} tintColorClassName="accent-chevron" />
+              </Pressable>
+            </Menu>
+            <SnoozeTimeRow
+              label="at"
+              accessibilityLabel={`${label} time`}
+              value={slot.time}
+              disabled={disabled}
+              onValueChange={(time) => onChange({ ...slot, time })}
+            />
+          </>
+        )}
+      </View>
+    </View>
+  );
+}
+
+export function SnoozeSettingsSection() {
+  const [open, setOpen] = useState(false);
+  const result = useAtomValue(mobilePreferencesAtom);
+  const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+  const ready = AsyncResult.isSuccess(result) && !result.waiting;
+  const customPreferences = AsyncResult.isSuccess(result)
+    ? (result.value.snoozePreferences ?? null)
+    : null;
+  const preferences = customPreferences ?? DEFAULT_SNOOZE_PRESETS;
+  const updateSlot = (index: number, slot: SnoozePresetSetting) =>
+    savePreferences({
+      transform: (current) => ({
+        snoozePreferences: (current.snoozePreferences ?? DEFAULT_SNOOZE_PRESETS).map(
+          (value, currentIndex) => (currentIndex === index ? slot : value),
+        ),
+      }),
+    });
+
+  return (
+    <SettingsSection title="Snooze">
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel="Snooze presets"
+        accessibilityState={{ expanded: open }}
+        onPress={() => setOpen((value) => !value)}
+        className="min-h-14 flex-row items-center justify-between gap-4 px-4 py-3"
+      >
+        <Text className="text-lg text-foreground android:text-base">Snooze presets</Text>
+        <Text className="text-base text-foreground-muted">{open ? "Hide" : "Edit"}</Text>
+      </Pressable>
+      {open ? (
+        <View className="gap-3">
+          <View>
+            {[0, 1, 2, 3, 4].map((index) => (
+              <SnoozeSlotEditor
+                key={index}
+                slot={preferences[index]!}
+                index={index}
+                disabled={!ready}
+                onChange={(value) => updateSlot(index, value)}
+              />
+            ))}
+            {customPreferences !== null ? (
+              <View className="px-4 py-3">
+                <Button
+                  label="Restore defaults"
+                  tone="secondary"
+                  disabled={!ready}
+                  onPress={() => savePreferences({ snoozePreferences: null })}
+                />
+              </View>
+            ) : null}
+          </View>
+          <Text textRole="footnote" className="px-4 pb-3 text-foreground-muted">
+            Presets stay in this order and use your local time zone.
+          </Text>
+        </View>
+      ) : null}
+    </SettingsSection>
+  );
+}
```

## apps/mobile/src/features/threads/thread-list-v2-items.tsx

Status: modified; +14; −3

```diff
@@ -1,3 +1,6 @@
+import { useAtomValue } from "@effect/atom-react";
+import { AsyncResult } from "effect/reactivity";
+import { mobilePreferencesAtom } from "../../state/preferences";
 import * as Dialog from "../../components/ui/Dialog";
 import type { ThreadRowProviderInstance } from "./thread-provider-instance";
 import {
@@ -735,11 +738,17 @@
     snoozable: canSnooze(thread, { now: snoozeGateNow }),
     snoozed: snoozedRow,
   });
+  const snoozePreferences = useAtomValue(mobilePreferencesAtom, (result) =>
+    AsyncResult.isSuccess(result) ? (result.value.snoozePreferences ?? null) : null,
+  );
   const snoozePresets = useMemo(
+    () => {
+      if (swipeActions.secondary !== "snooze") return [] as const;
+      // oxlint-disable-next-line react/purity -- The memo recomputes on the snoozePresetMinute tick, so preset times refresh every minute.
-    // oxlint-disable-next-line react/purity -- The memo recomputes on the snoozePresetMinute tick, so preset times refresh every minute.
-    () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
+      return resolveSnoozePresets(new Date(), snoozePreferences);
+    },
     // oxlint-disable-next-line react/memo-dependencies -- The minute tick refreshes relative snooze times while the row remains mounted.
-    [props.snoozePresetMinute, swipeActions.secondary],
+    [props.snoozePresetMinute, swipeActions.secondary, snoozePreferences],
   );
   const snoozePresetActions = useMemo<MenuAction[]>(
     () => [
@@ -909,6 +918,7 @@
       const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
         event: nativeEvent.event,
         displayedPresets: snoozePresets,
+        preferences: snoozePreferences,
         now: new Date(),
       });
       if (snoozeSelection._tag === "selected") {
@@ -938,6 +948,7 @@
       handleUnsnooze,
       setCustomSnoozeOpen,
       snoozePresets,
+      snoozePreferences,
     ],
   );
   const primaryAction = useMemo(() => {
```

## apps/mobile/src/features/threads/threadListV2.test.ts

Status: modified; +16; −0

```diff
@@ -14,6 +14,7 @@
   type ThreadMoveAvailability,
 } from "./threadOrder";
 import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { DEFAULT_SNOOZE_PRESETS } from "@t3tools/contracts/settings";
 import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
 import {
   CommandId,
@@ -58,6 +59,21 @@
 const NOW = "2026-06-02T00:00:00.000Z";
 
 describe("resolveThreadListV2SnoozeMenuSelection", () => {
+  it("uses the saved preferences when recomputing a selected snooze", () => {
+    const preferences = [...DEFAULT_SNOOZE_PRESETS];
+    preferences[0] = { mode: "relative", hours: 168 };
+    const now = new Date(2026, 4, 8, 10);
+    const selection = resolveThreadListV2SnoozeMenuSelection({
+      event: "snooze:preset-0",
+      displayedPresets: resolveSnoozePresets(now, preferences),
+      preferences,
+      now: new Date(2026, 4, 8, 11),
+    });
+    expect(selection._tag).toBe("selected");
+    if (selection._tag === "selected") {
+      expect(selection.preset.snoozedUntil).toBe(new Date(2026, 4, 15, 11).toISOString());
+    }
+  });
   it("recomputes presets that remain available instead of using old timestamps", () => {
     const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10));
     const selectedAt = new Date(2026, 4, 8, 10, 30);
```

## apps/mobile/src/features/threads/threadListV2.ts

Status: modified; +3; −1

```diff
@@ -7,6 +7,7 @@
   snoozeWakeLabel,
   threadWokeAt,
 } from "@t3tools/client-runtime/state/thread-settled";
+import type { SnoozePreferences } from "@t3tools/contracts/settings";
 import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
 import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
 import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
@@ -75,6 +76,7 @@
 export function resolveThreadListV2SnoozeMenuSelection(input: {
   readonly event: string;
   readonly displayedPresets: ReadonlyArray<SnoozePreset>;
+  readonly preferences?: SnoozePreferences;
   readonly now: Date;
 }):
   | { readonly _tag: "selected"; readonly preset: SnoozePreset }
@@ -82,7 +84,7 @@
   | { readonly _tag: "not-snooze" } {
   if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" };
 
-  const currentPreset = resolveSnoozePresets(input.now).find(
+  const currentPreset = resolveSnoozePresets(input.now, input.preferences).find(
     (candidate) => input.event === `snooze:${candidate.id}`,
   );
   if (currentPreset) return { _tag: "selected", preset: currentPreset };
```

## apps/mobile/src/persistence/mobile-preferences.ts

Status: modified; +7; −0

```diff
@@ -14,6 +14,7 @@
   type PullRequestMergeMethod,
   type SidebarProjectGroupingMode,
 } from "@t3tools/contracts";
+import { SnoozePreferences } from "@t3tools/contracts/settings";
 import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
 import type { FollowUpBehavior } from "../lib/followUpBehavior";
 import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority";
@@ -76,11 +77,13 @@
 });
 export type WorkspaceSelections = typeof WorkspaceSelections.Type;
 
+const isSnoozePreferences = Schema.is(SnoozePreferences);
 const isThreadListFilters = Schema.is(ThreadListFilters);
 const isWorkspaceSelections = Schema.is(WorkspaceSelections);
 const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);
 
 export interface Preferences {
+  readonly snoozePreferences?: SnoozePreferences;
   readonly navigationTabOrder?: readonly string[];
   readonly hiddenNavigationTabs?: readonly string[];
   readonly pullRequestHideTestFiles?: boolean;
@@ -158,6 +161,7 @@
 
 function sanitizePreferences(parsed: Preferences): Preferences {
   const preferences: {
+    snoozePreferences?: SnoozePreferences;
     navigationTabOrder?: readonly string[];
     hiddenNavigationTabs?: readonly string[];
     pullRequestHideTestFiles?: boolean;
@@ -300,6 +304,9 @@
   if (typeof parsed.planModeEnabled === "boolean") {
     preferences.planModeEnabled = parsed.planModeEnabled;
   }
+  if (isSnoozePreferences(parsed.snoozePreferences)) {
+    preferences.snoozePreferences = parsed.snoozePreferences;
+  }
   if (typeof parsed.workingShelfEnabled === "boolean") {
     preferences.workingShelfEnabled = parsed.workingShelfEnabled;
   }
```

## apps/web/src/components/Sidebar.snooze.ts

Status: modified; +4; −2

```diff
@@ -6,6 +6,7 @@
 } from "@t3tools/client-runtime/state/thread-settled";
 
 import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat";
+import { getClientSettings } from "../hooks/useSettings";
 
 export { snoozeWakeLabel, type SnoozePreset };
 
@@ -17,14 +18,15 @@
   now: Date,
   timestampFormat: TimestampFormat,
 ): ReadonlyArray<SnoozePreset> {
+  return resolveSharedSnoozePresets(now, getClientSettings().snoozePreferences).map((preset) => {
-  return resolveSharedSnoozePresets(now).map((preset) => {
     const wake = parseTimestampDate(preset.snoozedUntil);
     if (wake === null) return preset;
     const time = timeOfDayLabel(wake, timestampFormat);
     return {
       ...preset,
       whenLabel:
+        preset.id === "next-week" ||
+        (preset.id.startsWith("preset-") && wake.toDateString() !== now.toDateString())
-        preset.id === "next-week"
           ? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}`
           : time,
     };
```

## apps/web/src/components/settings/SettingsPanels.tsx

Status: modified; +91; −84

```diff
@@ -1,4 +1,5 @@
 import { SettingsGroup } from "./SettingsGroup";
+import { SnoozeSettings } from "./SnoozeSettings";
 import { NotificationSettings } from "./NotificationSettings";
 import { PRIVACY_POLICY_URL } from "../../legalLinks";
 import { CheckIcon, ChevronRightIcon } from "lucide-react";
@@ -419,6 +420,9 @@
     DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
       ? ["Environment identification"]
       : []),
+    ...(!Equal.equals(settings.snoozePreferences, DEFAULT_UNIFIED_SETTINGS.snoozePreferences)
+      ? ["Snooze presets"]
+      : []),
     ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
       ? ["Time format"]
       : []),
@@ -587,6 +591,7 @@
       mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled,
       redesignEnabled: DEFAULT_UNIFIED_SETTINGS.redesignEnabled,
       timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
+      snoozePreferences: DEFAULT_UNIFIED_SETTINGS.snoozePreferences,
       notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
       inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
       wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
@@ -1018,106 +1023,108 @@
             </Select>
           }
         />
+      </SettingsSection>
 
+      {supportsAutoSettlement ? (
+        <SettingsSection id="auto-settle" title="Auto-settle">
+          <SettingsRow
-        {supportsAutoSettlement ? (
-          <>
+            serverScoped
+            settingKeys={["sidebarAutoSettleOnMerge"]}
+            {...searchableSetting("auto-settle-merged-threads")}
+            description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
+            resetAction={
+              settings.sidebarAutoSettleOnMerge !==
+              DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
+                <SettingResetButton
+                  label="auto-settle on merge"
+                  onClick={() =>
+                    updateSettings({
+                      sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
+                    })
+                  }
+                />
+              ) : null
+            }
+            control={
+              <ScopedSwitch
+                settingKeys={["sidebarAutoSettleOnMerge"]}
+                checked={settings.sidebarAutoSettleOnMerge}
+                onCheckedChange={(checked) =>
+                  updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
+                }
+                aria-label="Auto-settle merged threads"
+              />
+            }
+          />
+
+          <SettingsRow
+            serverScoped
+            settingKeys={["sidebarAutoSettleAfterDays"]}
+            {...searchableSetting("auto-settle-inactive-threads")}
+            description="Sidebar threads with no activity for this long settle automatically."
+            resetAction={
+              settings.sidebarAutoSettleAfterDays !==
+              DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
+                <SettingResetButton
+                  label="auto-settle"
+                  onClick={() =>
+                    updateSettings({
+                      sidebarAutoSettleAfterDays:
+                        DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
+                    })
+                  }
+                />
+              ) : null
+            }
+            control={
+              <ScopedSwitch
+                settingKeys={["sidebarAutoSettleAfterDays"]}
+                checked={settings.sidebarAutoSettleAfterDays !== null}
+                onCheckedChange={(checked) =>
+                  updateSettings({
+                    sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
+                  })
+                }
+                aria-label="Auto-settle inactive threads"
+              />
+            }
+          />
+          {settings.sidebarAutoSettleAfterDays !== null ? (
             <SettingsRow
               serverScoped
-              settingKeys={["sidebarAutoSettleOnMerge"]}
-              {...searchableSetting("auto-settle-merged-threads")}
-              description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
-              resetAction={
-                settings.sidebarAutoSettleOnMerge !==
-                DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
-                  <SettingResetButton
+              settingKeys={["sidebarAutoSettleAfterDays"]}
+              {...searchableSetting("days-before-auto-settle")}
+              description="Any new activity un-settles a thread automatically."
-                    label="auto-settle on merge"
-                    onClick={() =>
-                      updateSettings({
-                        sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
-                      })
-                    }
-                  />
-                ) : null
-              }
               control={
-                <ScopedSwitch
-                  settingKeys={["sidebarAutoSettleOnMerge"]}
-                  checked={settings.sidebarAutoSettleOnMerge}
-                  onCheckedChange={(checked) =>
-                    updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
-                  }
-                  aria-label="Auto-settle merged threads"
+                <AutoSettleDaysInput
+                  value={settings.sidebarAutoSettleAfterDays}
+                  onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
                 />
               }
             />
+          ) : null}
+          {settings.sidebarAutoSettleAfterDays !== null && supportsAutoSettlementScope ? (
-
             <SettingsRow
               serverScoped
-              settingKeys={["sidebarAutoSettleAfterDays"]}
-              {...searchableSetting("auto-settle-inactive-threads")}
-              description="Sidebar threads with no activity for this long settle automatically."
-              resetAction={
-                settings.sidebarAutoSettleAfterDays !==
-                DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
-                  <SettingResetButton
-                    label="auto-settle"
-                    onClick={() =>
-                      updateSettings({
-                        sidebarAutoSettleAfterDays:
-                          DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
-                      })
-                    }
-                  />
-                ) : null
+              settingKeys={["sidebarAutoSettleKeepPullRequests"]}
+              title="Keep threads with a PR"
-              }
+              description="Inactivity does not settle threads with a pull request. Merge and close still do."
               control={
                 <ScopedSwitch
+                  settingKeys={["sidebarAutoSettleKeepPullRequests"]}
+                  checked={settings.sidebarAutoSettleKeepPullRequests}
-                  settingKeys={["sidebarAutoSettleAfterDays"]}
-                  checked={settings.sidebarAutoSettleAfterDays !== null}
                   onCheckedChange={(checked) =>
+                    updateSettings({ sidebarAutoSettleKeepPullRequests: checked })
-                    updateSettings({
-                      sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
-                    })
                   }
+                  aria-label="Keep threads with a PR"
-                  aria-label="Auto-settle inactive threads"
                 />
               }
             />
-            {settings.sidebarAutoSettleAfterDays !== null ? (
-              <SettingsRow
-                serverScoped
-                settingKeys={["sidebarAutoSettleAfterDays"]}
-                title={searchableSetting("days-before-auto-settle").title}
-                description="Any new activity un-settles a thread automatically."
-                control={
-                  <AutoSettleDaysInput
+          ) : null}
+        </SettingsSection>
-                    value={settings.sidebarAutoSettleAfterDays}
+      ) : null}
+
+      <SnoozeSettings />
-                    onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
-                  />
-                }
-              />
-            ) : null}
-            {settings.sidebarAutoSettleAfterDays !== null && supportsAutoSettlementScope ? (
-              <SettingsRow
-                serverScoped
-                settingKeys={["sidebarAutoSettleKeepPullRequests"]}
-                title="Keep threads with a PR"
-                description="Inactivity does not settle threads with a pull request. Merge and close still do."
-                control={
-                  <ScopedSwitch
-                    settingKeys={["sidebarAutoSettleKeepPullRequests"]}
-                    checked={settings.sidebarAutoSettleKeepPullRequests}
-                    onCheckedChange={(checked) =>
-                      updateSettings({ sidebarAutoSettleKeepPullRequests: checked })
-                    }
-                    aria-label="Keep threads with a PR"
-                  />
-                }
-              />
-            ) : null}
-          </>
-        ) : null}
-      </SettingsSection>
 
       <SettingsSection id="behavior" title="Behavior">
         <NotificationSettings />
```

## apps/web/src/components/settings/SnoozeSettings.browser.test.tsx

Status: added; +135; −0

```diff
@@ -1,0 +1,135 @@
+import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
+import { DEFAULT_CLIENT_SETTINGS, DEFAULT_SNOOZE_PRESETS } from "@t3tools/contracts/settings";
+import { expect, it, vi } from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+
+import { act, render } from "../../../test/render";
+import {
+  readBrowserClientSettings,
+  writeBrowserClientSettings,
+} from "../../clientPersistenceStorage";
+import {
+  __resetClientSettingsPersistenceForTests,
+  __setClientSettingsForTests,
+  getClientSettings,
+  persistClientSettingsPatch,
+  useMergedSettings,
+} from "../../hooks/useSettings";
+import { SnoozeSettings } from "./SnoozeSettings";
+
+import "../../index.css";
+
+vi.mock("./useScopedSettings", () => ({
+  useScopedSettings: () => useMergedSettings(DEFAULT_SERVER_SETTINGS),
+  useUpdateScopedSettings: () => persistClientSettingsPatch,
+  useClearScopedSettings: () => () => {},
+  useClearProjectOverrides: () => () => {},
+}));
+
+vi.mock("../../localApi", () => ({
+  ensureLocalApi: () => ({
+    persistence: {
+      getClientSettings: async () => readBrowserClientSettings(),
+      setClientSettings: async (settings: Parameters<typeof writeBrowserClientSettings>[0]) =>
+        writeBrowserClientSettings(settings),
+    },
+  }),
+}));
+
+function setup() {
+  const key = "t3code:client-settings:v1";
+  const previous = localStorage.getItem(key);
+  __resetClientSettingsPersistenceForTests();
+  const settings = {
+    ...DEFAULT_CLIENT_SETTINGS,
+    snoozePreferences: [
+      { mode: "relative" as const, hours: 8 },
+      ...DEFAULT_SNOOZE_PRESETS.slice(1),
+    ],
+  };
+  __setClientSettingsForTests(settings);
+  writeBrowserClientSettings(settings);
+  return {
+    [Symbol.dispose]() {
+      __resetClientSettingsPersistenceForTests();
+      if (previous === null) localStorage.removeItem(key);
+      else localStorage.setItem(key, previous);
+    },
+  };
+}
+
+it("ignores fractional hours without blocking later client-setting persistence", async () => {
+  using _settings = setup();
+  await using screen = await render(<SnoozeSettings />);
+  await userEvent.click(screen.getByRole("button", { name: "Snooze", exact: true }));
+  const hours = screen.container.querySelector<HTMLInputElement>(
+    'input[aria-label="Snooze slot 1 hours"]',
+  )!;
+
+  await userEvent.fill(hours, "1.5");
+  await act(() => persistClientSettingsPatch({ timestampFormat: "24-hour" }));
+
+  expect(readBrowserClientSettings()).toMatchObject({
+    timestampFormat: "24-hour",
+    snoozePreferences: [{ mode: "relative", hours: 8 }, ...DEFAULT_SNOOZE_PRESETS.slice(1)],
+  });
+  expect(getClientSettings().snoozePreferences?.[0]).toEqual({ mode: "relative", hours: 8 });
+
+  await userEvent.fill(hours, "168");
+  await act(() => persistClientSettingsPatch({ timestampFormat: "12-hour" }));
+  expect(readBrowserClientSettings()).toMatchObject({
+    timestampFormat: "12-hour",
+    snoozePreferences: [{ mode: "relative", hours: 168 }, ...DEFAULT_SNOOZE_PRESETS.slice(1)],
+  });
+});
+
+it("allows clearing and replacing a snooze time without persisting an empty value", async () => {
+  using _settings = setup();
+  await using screen = await render(<SnoozeSettings />);
+  await userEvent.click(screen.getByRole("button", { name: "Snooze", exact: true }));
+  const time = screen.container.querySelector<HTMLInputElement>(
+    'input[aria-label="Snooze slot 3 time"]',
+  )!;
+
+  await userEvent.fill(time, "");
+  await expect.element(time).toHaveValue("");
+  await act(() => persistClientSettingsPatch({ timestampFormat: "24-hour" }));
+  expect(readBrowserClientSettings()?.snoozePreferences?.[2]).toEqual({
+    mode: "absolute",
+    day: "today",
+    time: "18:00",
+  });
+  await expect.element(time).toHaveValue("");
+
+  await userEvent.fill(time, "06:30");
+  await vi.waitFor(() => {
+    expect(readBrowserClientSettings()?.snoozePreferences?.[2]).toEqual({
+      mode: "absolute",
+      day: "today",
+      time: "06:30",
+    });
+  });
+});
+
+it("restores an empty time on blur and reflects changes to the saved time", async () => {
+  using _settings = setup();
+  await using screen = await render(<SnoozeSettings />);
+  await userEvent.click(screen.getByRole("button", { name: "Snooze", exact: true }));
+  const time = screen.container.querySelector<HTMLInputElement>(
+    'input[aria-label="Snooze slot 3 time"]',
+  )!;
+  const hours = screen.container.querySelector<HTMLInputElement>(
+    'input[aria-label="Snooze slot 1 hours"]',
+  )!;
+
+  await userEvent.fill(time, "");
+  await expect.element(time).toHaveValue("");
+  await userEvent.click(hours);
+  await expect.element(time).toHaveValue("18:00");
+
+  await userEvent.fill(time, "");
+  const preferences = [...DEFAULT_SNOOZE_PRESETS];
+  preferences[2] = { mode: "absolute", day: "today", time: "20:00" };
+  await act(() => persistClientSettingsPatch({ snoozePreferences: preferences }));
+  await expect.element(time).toHaveValue("20:00");
+});
```

## apps/web/src/components/settings/SnoozeSettings.tsx

Status: added; +199; −0

```diff
@@ -1,0 +1,199 @@
+import { useState } from "react";
+import * as Schema from "effect/Schema";
+import {
+  DEFAULT_SNOOZE_PRESETS,
+  MAX_SNOOZE_HOURS,
+  SNOOZE_ABSOLUTE_DAYS,
+  SnoozeDay,
+  type SnoozePresetSetting,
+  SnoozeTime,
+} from "@t3tools/contracts/settings";
+import { Input } from "../ui/input";
+import {
+  NumberField,
+  NumberFieldDecrement,
+  NumberFieldGroup,
+  NumberFieldIncrement,
+  NumberFieldInput,
+} from "../ui/number-field";
+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
+import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
+import { SettingResetButton } from "./settingsLayout";
+import { FoldedSettingsSection } from "./FoldedSettingsSection";
+
+const isSnoozeTime = Schema.is(SnoozeTime);
+const isSnoozeDay = Schema.is(SnoozeDay);
+
+function SnoozeTimeInput({
+  label,
+  value,
+  onCommit,
+}: {
+  label: string;
+  value: string;
+  onCommit: (time: string) => void;
+}) {
+  const [draft, setDraft] = useState(value);
+  const [draftSource, setDraftSource] = useState(value);
+  if (draftSource !== value) {
+    setDraftSource(value);
+    setDraft(value);
+  }
+
+  return (
+    <Input
+      size="sm"
+      type="time"
+      aria-label={label}
+      value={draft}
+      onChange={(event) => {
+        const time = event.target.value;
+        setDraft(time);
+        if (isSnoozeTime(time)) onCommit(time);
+      }}
+      onBlur={() => setDraft(value)}
+    />
+  );
+}
+
+function SnoozeSlotEditor({
+  slot,
+  index,
+  onChange,
+}: {
+  slot: SnoozePresetSetting;
+  index: number;
+  onChange: (slot: SnoozePresetSetting) => void;
+}) {
+  const label = `Snooze slot ${index + 1}`;
+  return (
+    <div
+      role="group"
+      aria-label={label}
+      className="flex min-w-0 flex-wrap items-center justify-between gap-3 px-3 py-2.5 sm:px-4"
+    >
+      <div className="w-28 shrink-0">
+        <Select
+          value={slot.mode}
+          onValueChange={(mode) => {
+            if (mode === slot.mode) return;
+            if (mode === "relative") onChange({ mode, hours: 1 });
+            if (mode === "absolute") onChange({ mode, day: "tomorrow", time: "09:00" });
+          }}
+        >
+          <SelectTrigger variant="ghost" size="sm" className="w-full" aria-label={`${label} mode`}>
+            <SelectValue>{slot.mode === "relative" ? "Relative" : "Absolute"}</SelectValue>
+          </SelectTrigger>
+          <SelectPopup alignItemWithTrigger={false}>
+            <SelectItem value="relative">Relative</SelectItem>
+            <SelectItem value="absolute">Absolute</SelectItem>
+          </SelectPopup>
+        </Select>
+      </div>
+      <div className="flex min-w-0 flex-wrap items-center gap-3">
+        {slot.mode === "relative" ? (
+          <div className="flex shrink-0 items-center gap-2">
+            <div className="w-32">
+              <NumberField
+                size="sm"
+                value={slot.hours}
+                min={1}
+                max={MAX_SNOOZE_HOURS}
+                step={1}
+                onValueChange={(value) => {
+                  if (
+                    value !== null &&
+                    Number.isInteger(value) &&
+                    value >= 1 &&
+                    value <= MAX_SNOOZE_HOURS
+                  ) {
+                    onChange({ ...slot, hours: value });
+                  }
+                }}
+              >
+                <NumberFieldGroup>
+                  <NumberFieldDecrement aria-label={`Decrease ${label.toLowerCase()} hours`} />
+                  <NumberFieldInput aria-label={`${label} hours`} />
+                  <NumberFieldIncrement aria-label={`Increase ${label.toLowerCase()} hours`} />
+                </NumberFieldGroup>
+              </NumberField>
+            </div>
+            <span className="text-xs text-muted-foreground">hours</span>
+          </div>
+        ) : (
+          <>
+            <div className="w-36">
+              <Select
+                value={slot.day}
+                onValueChange={(day) => {
+                  if (isSnoozeDay(day)) onChange({ ...slot, day });
+                }}
+              >
+                <SelectTrigger size="sm" aria-label={`${label} day`}>
+                  <SelectValue>
+                    {(day: string) => day.charAt(0).toUpperCase() + day.slice(1)}
+                  </SelectValue>
+                </SelectTrigger>
+                <SelectPopup align="end" alignItemWithTrigger={false}>
+                  {SNOOZE_ABSOLUTE_DAYS.map((day) => (
+                    <SelectItem key={day} value={day}>
+                      {day.charAt(0).toUpperCase() + day.slice(1)}
+                    </SelectItem>
+                  ))}
+                </SelectPopup>
+              </Select>
+            </div>
+            <span className="text-xs text-muted-foreground">at</span>
+            <div className="w-32">
+              <SnoozeTimeInput
+                label={`${label} time`}
+                value={slot.time}
+                onCommit={(time) => onChange({ ...slot, time })}
+              />
+            </div>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function SnoozeSettings() {
+  const settings = useScopedSettings();
+  const updateSettings = useUpdateScopedSettings();
+  const preferences = settings.snoozePreferences ?? DEFAULT_SNOOZE_PRESETS;
+
+  return (
+    <FoldedSettingsSection
+      id="snooze-presets"
+      title="Snooze"
+      headerPlacement="outside"
+      control={
+        settings.snoozePreferences !== null ? (
+          <SettingResetButton
+            label="snooze presets"
+            onClick={() => updateSettings({ snoozePreferences: null })}
+          />
+        ) : null
+      }
+    >
+      {[0, 1, 2, 3, 4].map((index) => (
+        <SnoozeSlotEditor
+          key={index}
+          slot={preferences[index]!}
+          index={index}
+          onChange={(next) =>
+            updateSettings({
+              snoozePreferences: preferences.map((current, currentIndex) =>
+                currentIndex === index ? next : current,
+              ),
+            })
+          }
+        />
+      ))}
+      <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
+        Presets stay in this order and use your local time zone.
+      </p>
+    </FoldedSettingsSection>
+  );
+}
```

## apps/web/src/components/settings/settingsSearch.ts

Status: modified; +6; −0

```diff
@@ -367,6 +367,12 @@
     searchTerms: ["beta share guest invite link teammate watch t3 connect tunnel"],
   },
   {
+    id: "snooze-presets",
+    title: "Snooze presets",
+    to: "/settings/general",
+    searchTerms: ["tomorrow next week Monday morning evening time hours seven days"],
+  },
+  {
     id: "auto-settle-inactive-threads",
     title: "Auto-settle inactive threads",
     to: "/settings/general",
```

## docs/user/thread-sidebar.md

Status: modified; +13; −0

```diff
@@ -248,6 +248,19 @@
 
 ## Snooze until later
 
+Open **Settings → General → Snooze** on web and desktop,
+or **Settings → Thread behavior → Snooze presets** on mobile, to configure five snooze slots in a fixed order.
+Each slot uses either a relative duration of 1–168 hours, or an absolute day and time.
+Choose Today, Tomorrow, or a weekday; weekdays use their next occurrence, including today
+if the chosen time is still ahead. A Today slot disappears from the menu after its time passes.
+When slots resolve to the same wake time, only the first appears in the menu.
+
+For example, replace the one-hour slot with **Relative: 168 hours**, and set two other
+slots to **Absolute: Tomorrow at 06:00** and **Absolute: Monday at 06:00**. Relative hours add
+elapsed time, including across daylight saving changes.
+Preferences apply on this device. The existing presets stay unchanged until you save a custom
+set; **Restore defaults** brings them back.
+
 Choose **Snooze → Custom…** from a thread's menu to pick a date and time in your
 local time zone, or a duration in minutes, hours, or days. Durations start when
 you confirm; one day means 24 hours. On web and desktop, you can also snooze
```

## packages/client-runtime/src/state/threadSettled.ts

Status: modified; +85; −2

```diff
@@ -1,5 +1,6 @@
 // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
 import * as DateTime from "effect/DateTime";
+import type { SnoozePreferences } from "@t3tools/contracts/settings";
 
 import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts";
 
@@ -174,7 +175,13 @@
 const EVENING_HOUR = 18;
 const MORNING_HOUR = 9;
 
+export type SnoozePresetId =
+  | "hour"
+  | "three-hours"
+  | "evening"
+  | "tomorrow"
+  | "next-week"
+  | `preset-${number}`;
-export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week";
 
 export interface SnoozePreset {
   readonly id: SnoozePresetId;
@@ -202,7 +209,7 @@
   return next;
 }
 
-export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> {
+function resolveDefaultSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> {
   const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS));
   const inThreeHours = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + 3 * HOUR_MS));
   const presets: SnoozePreset[] = [
@@ -252,6 +259,82 @@
   return presets;
 }
 
+const SNOOZE_WEEKDAYS = [
+  "sunday",
+  "monday",
+  "tuesday",
+  "wednesday",
+  "thursday",
+  "friday",
+  "saturday",
+];
+
+function snoozeAtDayAndTime(now: Date, days: number, time: string): Date {
+  const [hour, minute] = time.split(":").map(Number);
+  const wake = addSnoozeDays(now, days);
+  // Set the time on the destination day so today's DST gap cannot shift a later wake.
+  wake.setHours(hour!, minute!, 0, 0);
+  return wake;
+}
+
+export function resolveSnoozePresets(
+  now: Date,
+  preferences: SnoozePreferences = null,
+): ReadonlyArray<SnoozePreset> {
+  const configuredPresets = preferences?.flatMap((slot, index): SnoozePreset[] => {
+    let wake: Date;
+    let label: string;
+    if (slot.mode === "relative") {
+      wake = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + slot.hours * HOUR_MS));
+      const days = slot.hours / 24;
+      label = Number.isInteger(days)
+        ? `In ${days} ${days === 1 ? "day" : "days"}`
+        : `In ${slot.hours} ${slot.hours === 1 ? "hour" : "hours"}`;
+    } else {
+      let days =
+        slot.day === "today"
+          ? 0
+          : slot.day === "tomorrow"
+            ? 1
+            : (SNOOZE_WEEKDAYS.indexOf(slot.day) - now.getDay() + 7) % 7;
+      wake = snoozeAtDayAndTime(now, days, slot.time);
+      if (slot.day !== "today" && slot.day !== "tomorrow" && wake.getTime() <= now.getTime()) {
+        days += 7;
+        wake = snoozeAtDayAndTime(now, days, slot.time);
+      }
+      if (days === 0) {
+        const hour = wake.getHours();
+        label =
+          hour < 12 ? "This morning" : hour < EVENING_HOUR ? "This afternoon" : "This evening";
+      } else if (days === 1) {
+        label = "Tomorrow";
+      } else if (((now.getDay() + 6) % 7) + days >= 7) {
+        label = "Next week";
+      } else {
+        label = slot.day.charAt(0).toUpperCase() + slot.day.slice(1);
+      }
+    }
+    if (wake.getTime() <= now.getTime()) return [];
+    return [
+      {
+        id: `preset-${index}`,
+        label,
+        whenLabel:
+          label === "Next week"
+            ? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(wake)}`
+            : snoozeTimeOfDayLabel(wake),
+        snoozedUntil: wake.toISOString(),
+      },
+    ];
+  });
+  const seenWakeTimes = new Set<string>();
+  return (configuredPresets ?? resolveDefaultSnoozePresets(now)).filter((preset) => {
+    if (seenWakeTimes.has(preset.snoozedUntil)) return false;
+    seenWakeTimes.add(preset.snoozedUntil);
+    return true;
+  });
+}
+
 export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: string }): string {
   const wakeMs = Date.parse(snoozedUntil);
   const nowMs = Date.parse(options.now);
```

## packages/client-runtime/src/state/threadSnoozed.test.ts

Status: modified; +206; −4

```diff
@@ -1,6 +1,7 @@
 // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries.
 import { ThreadId } from "@t3tools/contracts";
 import { TurnId } from "@t3tools/contracts";
+import { DEFAULT_SNOOZE_PRESETS } from "@t3tools/contracts/settings";
 import { describe, expect, it } from "vite-plus/test";
 
 import {
@@ -273,7 +274,210 @@
 });
 
 describe("resolveSnoozePresets", () => {
+  it("preserves the existing presets when no custom slots are saved", () => {
+    const presets = resolveSnoozePresets(localDate(2026, 4, 6, 5));
+    expect(presets.map((preset) => preset.id)).toEqual([
+      "hour",
+      "three-hours",
+      "evening",
+      "tomorrow",
+      "next-week",
+    ]);
+    expect(presets.find((preset) => preset.id === "hour")?.snoozedUntil).toBe(
+      localDate(2026, 4, 6, 6).toISOString(),
+    );
+    expect(presets.find((preset) => preset.id === "tomorrow")?.snoozedUntil).toBe(
+      localDate(2026, 4, 7, 9).toISOString(),
+    );
+    expect(presets.find((preset) => preset.id === "next-week")?.snoozedUntil).toBe(
+      localDate(2026, 4, 13, 9).toISOString(),
+    );
+  });
+
+  it("resolves five relative and absolute slots in their fixed order", () => {
+    const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), [
+      { mode: "relative", hours: 168 },
+      { mode: "relative", hours: 3 },
+      { mode: "absolute", day: "today", time: "18:00" },
+      { mode: "absolute", day: "tomorrow", time: "06:00" },
+      { mode: "absolute", day: "monday", time: "06:00" },
+    ]);
+    expect(presets.map((preset) => preset.id)).toEqual([
+      "preset-0",
+      "preset-1",
+      "preset-2",
+      "preset-3",
+      "preset-4",
+    ]);
+    expect(presets.map((preset) => preset.snoozedUntil)).toEqual([
+      localDate(2026, 4, 15, 10).toISOString(),
+      localDate(2026, 4, 8, 13).toISOString(),
+      localDate(2026, 4, 8, 18).toISOString(),
+      localDate(2026, 4, 9, 6).toISOString(),
+      localDate(2026, 4, 13, 6).toISOString(),
+    ]);
+    expect(presets[0]?.label).toBe("In 7 days");
+    expect(presets[1]?.label).toBe("In 3 hours");
+    expect(presets[2]?.label).toBe("This evening");
+    expect(presets[3]?.label).toBe("Tomorrow");
+    expect(presets[4]?.label).toBe("Next week");
+  });
+
+  it("preserves the original menu labels after saving the default slots", () => {
+    const now = localDate(2026, 4, 8, 10);
+    const defaults = resolveSnoozePresets(now);
+    const configured = resolveSnoozePresets(now, DEFAULT_SNOOZE_PRESETS);
+    expect(configured.map((preset) => preset.label)).toEqual(
+      defaults.map((preset) => preset.label),
+    );
+    expect(configured.map((preset) => preset.whenLabel)).toEqual(
+      defaults.map((preset) => preset.whenLabel),
+    );
+  });
+
-  it("drops the evening choice once evening is near or past", () => {
+  it.each([
+    [1, "In 1 hour"],
+    [3, "In 3 hours"],
+    [24, "In 1 day"],
+    [48, "In 2 days"],
+    [25, "In 25 hours"],
+    [168, "In 7 days"],
+  ])("labels a relative duration of %i hours as %s", (hours, expected) => {
+    const preferences = [...DEFAULT_SNOOZE_PRESETS];
+    preferences[0] = { mode: "relative", hours };
+    expect(resolveSnoozePresets(localDate(2026, 4, 8, 10), preferences)[0]?.label).toBe(expected);
+  });
+
+  it.each([
+    ["today", "11:00", "This morning"],
+    ["today", "12:00", "This afternoon"],
+    ["today", "17:59", "This afternoon"],
+    ["today", "18:00", "This evening"],
+    ["wednesday", "15:00", "This afternoon"],
+    ["tomorrow", "06:00", "Tomorrow"],
+    ["thursday", "06:00", "Tomorrow"],
+    ["friday", "06:00", "Friday"],
+    ["monday", "06:00", "Next week"],
+    ["wednesday", "09:00", "Next week"],
+  ] as const)("labels an absolute %s slot at %s as %s", (day, time, expected) => {
+    const preferences = [...DEFAULT_SNOOZE_PRESETS];
+    preferences[0] = { mode: "absolute", day, time };
+    expect(resolveSnoozePresets(localDate(2026, 4, 8, 10), preferences)[0]?.label).toBe(expected);
+  });
+
+  it.each([
+    [10, "sunday", "Sunday"],
+    [10, "saturday", "Tomorrow"],
+    [12, "monday", "Tomorrow"],
+    [12, "tuesday", "Next week"],
+    [12, "sunday", "Next week"],
+  ] as const)("uses Monday as the start of the week on April %i for %s", (date, day, expected) => {
+    const preferences = [...DEFAULT_SNOOZE_PRESETS];
+    preferences[0] = { mode: "absolute", day, time: "06:00" };
+    expect(resolveSnoozePresets(localDate(2026, 4, date, 10), preferences)[0]?.label).toBe(
+      expected,
+    );
+  });
+
+  it.each([
+    [5, 6],
+    [6, 13],
+    [7, 13],
+  ])("chooses the next weekday occurrence at %i:00", (hour, day) => {
+    const preferences = [...DEFAULT_SNOOZE_PRESETS];
+    preferences[0] = { mode: "absolute", day: "monday", time: "06:00" };
+    const presets = resolveSnoozePresets(localDate(2026, 4, 6, hour), preferences);
+    expect(presets.find((preset) => preset.id === "preset-0")?.snoozedUntil).toBe(
+      localDate(2026, 4, day!, 6).toISOString(),
+    );
+  });
+
+  it("deduplicates configured slots that resolve to the same wake time", () => {
+    const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7), DEFAULT_SNOOZE_PRESETS);
+    expect(presets.map((preset) => preset.id)).toEqual([
+      "preset-0",
+      "preset-1",
+      "preset-2",
+      "preset-3",
+    ]);
+    expect(presets.find((preset) => preset.id === "preset-3")?.snoozedUntil).toBe(
+      localDate(2026, 8, 31, 9).toISOString(),
+    );
+  });
+
+  it("deduplicates relative and absolute wakes while retaining distinct times with the same label", () => {
+    const presets = resolveSnoozePresets(localDate(2026, 4, 8, 15), [
+      { mode: "absolute", day: "today", time: "18:00" },
+      { mode: "relative", hours: 3 },
+      { mode: "relative", hours: 3 },
+      { mode: "absolute", day: "tomorrow", time: "06:00" },
+      { mode: "absolute", day: "tomorrow", time: "09:00" },
+    ]);
+    expect(presets.map(({ id, label, snoozedUntil }) => ({ id, label, snoozedUntil }))).toEqual([
+      {
+        id: "preset-0",
+        label: "This evening",
+        snoozedUntil: localDate(2026, 4, 8, 18).toISOString(),
+      },
+      {
+        id: "preset-3",
+        label: "Tomorrow",
+        snoozedUntil: localDate(2026, 4, 9, 6).toISOString(),
+      },
+      {
+        id: "preset-4",
+        label: "Tomorrow",
+        snoozedUntil: localDate(2026, 4, 9, 9).toISOString(),
+      },
+    ]);
+  });
+
+  it("deduplicates default wakes that coincide", () => {
+    const presets = resolveSnoozePresets(localDate(2026, 4, 8, 15));
+    expect(presets.map((preset) => preset.id)).toEqual([
+      "hour",
+      "three-hours",
+      "tomorrow",
+      "next-week",
+    ]);
+  });
+
+  it("omits a Today slot after its time without changing the remaining slot identities", () => {
+    const presets = resolveSnoozePresets(localDate(2026, 4, 8, 19), DEFAULT_SNOOZE_PRESETS);
+    expect(presets.map((preset) => preset.id)).toEqual([
+      "preset-0",
+      "preset-1",
+      "preset-3",
+      "preset-4",
+    ]);
+  });
+
+  it.each([
+    ["2026-03-06T17:30:00-05:00", "2026-03-13T22:30:00.000Z"],
+    ["2026-10-30T17:30:00-04:00", "2026-11-06T21:30:00.000Z"],
+  ])("adds elapsed hours across daylight saving changes from %s", (timestamp, expectedWake) => {
+    const now = new Date(timestamp);
+    const preferences = [...DEFAULT_SNOOZE_PRESETS];
+    preferences[0] = { mode: "relative", hours: 168 };
+    const presets = resolveSnoozePresets(now, preferences);
+    expect(presets[0]?.snoozedUntil).toBe(expectedWake);
+    expect(presets[0]?.label).toBe("In 7 days");
+  });
+
+  it.each(["tomorrow", "sunday"] as const)(
+    "applies the absolute time on the target day for %s across daylight saving changes",
+    (day) => {
+      const preferences = [...DEFAULT_SNOOZE_PRESETS];
+      preferences[0] = { mode: "absolute", day, time: "02:30" };
+      const now = localDate(2026, 3, 8, day === "tomorrow" ? 1 : 4);
+      const presets = resolveSnoozePresets(now, preferences);
+      expect(presets[0]?.snoozedUntil).toBe(
+        localDate(2026, 3, day === "tomorrow" ? 9 : 15, 2, 30).toISOString(),
+      );
+    },
+  );
+
+  it("keeps the original evening visibility rule before customization", () => {
     expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([
       "hour",
       "three-hours",
@@ -282,7 +486,7 @@
     ]);
   });
 
-  it("drops next week on Sundays, when it lands on the same Monday as tomorrow", () => {
+  it("keeps the original Sunday deduplication before customization", () => {
     const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1));
     expect(presets.map((preset) => preset.id)).toEqual([
       "hour",
@@ -290,8 +494,6 @@
       "evening",
       "tomorrow",
     ]);
-    const tomorrow = new Date(presets.find((preset) => preset.id === "tomorrow")!.snoozedUntil);
-    expect(tomorrow.getDay()).toBe(1);
   });
 });
 
```

## packages/contracts/src/settings.test.ts

Status: modified; +38; −0

```diff
@@ -8,6 +8,7 @@
   ClientSettingsPatch,
   ClaudeSettings,
   DEFAULT_SERVER_SETTINGS,
+  DEFAULT_SNOOZE_PRESETS,
   globalIdleCompaction,
   idleCompactionPatch,
   requiredScopesForProjectMutation,
@@ -370,3 +371,40 @@
     expect(decodeServerSettingsPatch(input)).toEqual(input);
   });
 });
+
+describe("ClientSettings snooze preferences", () => {
+  it("preserves legacy defaults until five custom slots are saved", () => {
+    expect(decodeClientSettings({}).snoozePreferences).toBeNull();
+    const snoozePreferences = [...DEFAULT_SNOOZE_PRESETS];
+    snoozePreferences[0] = { mode: "relative", hours: 168 };
+    snoozePreferences[3] = { mode: "absolute", day: "tomorrow", time: "06:00" };
+    snoozePreferences[4] = { mode: "absolute", day: "monday", time: "06:00" };
+    const settings = decodeClientSettings({ snoozePreferences });
+    expect(decodeClientSettings(encodeClientSettings(settings)).snoozePreferences).toEqual(
+      snoozePreferences,
+    );
+    expect(decodeClientSettingsPatch({ snoozePreferences })).toEqual({ snoozePreferences });
+    expect(decodeClientSettingsPatch({ snoozePreferences: null })).toEqual({
+      snoozePreferences: null,
+    });
+  });
+
+  it.each([
+    { mode: "relative", hours: 0 },
+    { mode: "relative", hours: -1 },
+    { mode: "relative", hours: 169 },
+    { mode: "relative", hours: 1.5 },
+    { mode: "absolute", day: "monday", time: "24:00" },
+    { mode: "absolute", day: "tomorrow", time: "18:60" },
+    { mode: "absolute", day: "unknown", time: "06:00" },
+  ])("rejects invalid snooze slots %j", (slot) => {
+    expect(() =>
+      decodeClientSettingsPatch({ snoozePreferences: [slot, ...DEFAULT_SNOOZE_PRESETS.slice(1)] }),
+    ).toThrow();
+  });
+
+  it.each([0, 4, 6])("rejects a custom set with %i slots", (length) => {
+    const slots = Array.from({ length }, () => DEFAULT_SNOOZE_PRESETS[0]);
+    expect(() => decodeClientSettingsPatch({ snoozePreferences: slots })).toThrow();
+  });
+});
```

## packages/contracts/src/settings.ts

Status: modified; +41; −0

```diff
@@ -306,7 +306,47 @@
   readonly providerModelPreferences: typeof ProviderModelPreferences.Type;
 };
 
+export const SnoozeTime = Schema.String.check(Schema.isPattern(/^([01]\d|2[0-3]):[0-5]\d$/));
+export const SNOOZE_ABSOLUTE_DAYS = [
+  "today",
+  "tomorrow",
+  "monday",
+  "tuesday",
+  "wednesday",
+  "thursday",
+  "friday",
+  "saturday",
+  "sunday",
+] as const;
+export const SnoozeDay = Schema.Literals(SNOOZE_ABSOLUTE_DAYS);
+export const MAX_SNOOZE_HOURS = 168;
+export const SnoozePresetSetting = Schema.Union([
+  Schema.Struct({
+    mode: Schema.Literal("relative"),
+    hours: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_SNOOZE_HOURS })),
+  }),
+  Schema.Struct({
+    mode: Schema.Literal("absolute"),
+    day: SnoozeDay,
+    time: SnoozeTime,
+  }),
+]);
+export type SnoozePresetSetting = typeof SnoozePresetSetting.Type;
+export const DEFAULT_SNOOZE_PRESETS: readonly SnoozePresetSetting[] = [
+  { mode: "relative", hours: 1 },
+  { mode: "relative", hours: 3 },
+  { mode: "absolute", day: "today", time: "18:00" },
+  { mode: "absolute", day: "tomorrow", time: "09:00" },
+  { mode: "absolute", day: "monday", time: "09:00" },
+];
+// Null preserves the existing contextual presets until the user saves a custom set.
+export const SnoozePreferences = Schema.NullOr(
+  Schema.Array(SnoozePresetSetting).check(Schema.isMinLength(5), Schema.isMaxLength(5)),
+);
+export type SnoozePreferences = typeof SnoozePreferences.Type;
+
 export const ClientSettingsSchema = Schema.Struct({
+  snoozePreferences: SnoozePreferences.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
   notificationMode: NotificationMode.pipe(
     Schema.withDecodingDefault(Effect.succeed("off" as const)),
   ),
@@ -1607,6 +1647,7 @@
 }
 
 export const ClientSettingsPatch = Schema.Struct({
+  snoozePreferences: Schema.optionalKey(SnoozePreferences),
   notificationMode: Schema.optionalKey(NotificationMode),
   inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
   diffColorScheme: Schema.optionalKey(DiffColorScheme),
```
