From a9240b77efb0800ca44983f295c5e7165dc1ea60 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:02:20 +0000 Subject: [PATCH 1/6] feat(web): choose the hour Tomorrow and Next week snoozes wake Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/Sidebar.snooze.test.ts | 7 +++ apps/web/src/components/Sidebar.snooze.ts | 5 +- apps/web/src/components/Sidebar.tsx | 12 +++-- .../settings/SettingsPanels.restore.test.tsx | 11 ++++ .../components/settings/SettingsPanels.tsx | 51 ++++++++++++++++++- .../src/components/settings/settingsSearch.ts | 6 +++ apps/web/src/hooks/useThreadActionMenu.ts | 4 +- docs/user/thread-sidebar.md | 3 ++ .../client-runtime/src/state/threadSettled.ts | 14 +++-- .../src/state/threadSnoozed.test.ts | 10 ++++ packages/contracts/src/settings.ts | 9 ++++ 11 files changed, 119 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 16e17e4217eb..daa10c14d9c3 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -61,6 +61,13 @@ describe("resolveSnoozePresets", () => { expect(nextWeek.getDay()).toBe(1); expect(nextWeek.getDate()).toBe(13); }); + it("labels tomorrow and next week with the chosen morning hour", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour", 5); + + expect(presets.find((preset) => preset.id === "tomorrow")!.whenLabel).toBe("05:00"); + expect(presets.find((preset) => preset.id === "next-week")!.whenLabel).toMatch(/Mon 05:00/); + }); + it("formats preset times with the selected clock preference", () => { const twelveHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "12-hour"); const twentyFourHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour"); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index e7b980279a4b..a85fc1a1467b 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -1,4 +1,4 @@ -import type { TimestampFormat } from "@t3tools/contracts/settings"; +import type { SnoozeMorningHour, TimestampFormat } from "@t3tools/contracts/settings"; import { resolveSnoozePresets as resolveSharedSnoozePresets, snoozeWakeLabel, @@ -18,8 +18,9 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { export function resolveSnoozePresets( now: Date, timestampFormat: TimestampFormat, + morningHour?: SnoozeMorningHour, ): ReadonlyArray { - return resolveSharedSnoozePresets(now).map((preset) => { + return resolveSharedSnoozePresets(now, morningHour).map((preset) => { const wake = parseTimestampDate(preset.snoozedUntil); if (wake === null) return preset; const time = timeOfDayLabel(wake, timestampFormat); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf862c50f46d..c68b2083d3e7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -549,11 +549,12 @@ function SnoozeMenuButton(props: { timestampFormat: TimestampFormat; }) { const { open, onOpenChange, onSnooze, timestampFormat } = props; + const snoozeMorningHour = useClientSettings((s) => s.snoozeMorningHour); // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( - () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []), - [open, timestampFormat], + () => (open ? resolveSnoozePresets(new Date(), timestampFormat, snoozeMorningHour) : []), + [open, timestampFormat, snoozeMorningHour], ); return ( @@ -2332,6 +2333,7 @@ export default function Sidebar() { const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const snoozeMorningHour = useClientSettings((s) => s.snoozeMorningHour); const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { @@ -4201,7 +4203,7 @@ export default function Sidebar() { const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, snoozeMorningHour); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -4366,6 +4368,7 @@ export default function Sidebar() { settleThreads, updateThreadMetadata, timestampFormat, + snoozeMorningHour, ], ); @@ -4456,7 +4459,7 @@ export default function Sidebar() { const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, snoozeMorningHour); const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( @@ -4704,6 +4707,7 @@ export default function Sidebar() { startThreadRename, updateThreadMetadata, timestampFormat, + snoozeMorningHour, ], ); diff --git a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx index 88c9cb1776e3..3c88a4d21b83 100644 --- a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx +++ b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx @@ -78,6 +78,17 @@ describe("restoring V2 settings", () => { expect(state.update.mock.calls[0]?.[0][key]).toBe(DEFAULT_UNIFIED_SETTINGS[key]); }); + it("restores the snooze wake time", async () => { + state.settings = { ...DEFAULT_UNIFIED_SETTINGS, snoozeMorningHour: 5 }; + hooks.beginRender(); + const restore = useSettingsRestore(); + + expect(restore.changedSettingLabels).toEqual(["Snooze wake time"]); + await restore.restoreDefaults(); + + expect(state.update.mock.calls[0]?.[0].snoozeMorningHour).toBe(9); + }); + it("does not reset settings after cancellation", async () => { state.settings = { ...DEFAULT_UNIFIED_SETTINGS, autoResumeLimitedThreads: true }; state.confirm.mockResolvedValue(false); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1104f875f6c5..99009f715d8b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -12,6 +12,7 @@ import { type ProviderInstanceId, type ScopedThreadRef, type SidebarProjectGroupingMode, + type TimestampFormat, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -100,7 +101,7 @@ import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatRelativeTimeLabel, formatShortTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { @@ -197,6 +198,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const HOURS_OF_DAY = Array.from({ length: 24 }, (_, hour) => hour); + +function hourOfDayLabel(hour: number, timestampFormat: TimestampFormat): string { + return formatShortTimestamp(new Date(2000, 0, 1, hour).toISOString(), timestampFormat); +} + const CHAT_WIDTH_LABELS: Record = { comfortable: "Comfortable", wide: "Wide", @@ -586,6 +593,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads ? ["Snooze limited threads"] : []), + ...(settings.snoozeMorningHour !== DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour + ? ["Snooze wake time"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.persistComposerContextStrip !== DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip @@ -706,6 +716,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, + settings.snoozeMorningHour, settings.sidebarProjectGroupingMode, settings.sidebarProjectSortOrder, settings.sidebarWorkingShelfEnabled, @@ -813,6 +824,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, + snoozeMorningHour: DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -2362,6 +2374,43 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + snoozeMorningHour: DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour, + }) + } + /> + ) : null + } + control={ + + } + /> s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const snoozeMorningHour = useClientSettings((s) => s.snoozeMorningHour); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path }); @@ -140,7 +141,7 @@ export function useThreadActionMenu(input: { titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; - const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const snoozePresets = resolveSnoozePresets(now, timestampFormat, snoozeMorningHour); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, projectFilter: null, @@ -345,6 +346,7 @@ export function useThreadActionMenu(input: { settleThread, snoozeThread, threadRef, + snoozeMorningHour, timestampFormat, unsettleThread, unsnoozeThread, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index eb7c4e6a9ffd..a4e1ac848933 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -223,3 +223,6 @@ Choose **Snooze → Custom…** from a thread's menu to pick a date and time in 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 several selected threads together. Choose **Wake thread** to bring a thread back early. + +**Tomorrow** and **Next week** wake at 9:00 by default. On web and desktop, change the hour in +**Settings → General → Snooze wake time**. diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 104414a9201d..6fb05f2c1847 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,4 +1,5 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. +import { DEFAULT_SNOOZE_MORNING_HOUR } from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; interface SettlementRunLike { @@ -212,7 +213,6 @@ export function threadWokeAt( const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; -const MORNING_HOUR = 9; export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; @@ -250,9 +250,13 @@ function addSnoozeDays(base: Date, days: number): Date { * appears while it is meaningfully before evening; after that the calendar * choices start at "Tomorrow". Calendar presets that land on the same * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday - * morning, so only "Tomorrow" is offered. + * morning, so only "Tomorrow" is offered. Both calendar presets wake at the + * user's `morningHour`. */ -export function resolveSnoozePresets(now: Date): ReadonlyArray { +export function resolveSnoozePresets( + now: Date, + morningHour: number = DEFAULT_SNOOZE_MORNING_HOUR, +): ReadonlyArray { const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS)); const inThreeHours = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + 3 * HOUR_MS)); const presets: SnoozePreset[] = [ @@ -280,7 +284,7 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { }); } - const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR); + const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), morningHour); presets.push({ id: "tomorrow", label: "Tomorrow", @@ -289,7 +293,7 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { }); const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; - const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); + const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), morningHour); if (nextWeek.getTime() !== tomorrow.getTime()) { presets.push({ id: "next-week", diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 793d5a4a3a95..ee1bf4fa5e70 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -354,6 +354,16 @@ describe("resolveSnoozePresets", () => { expect(nextWeek.getDate()).toBe(13); }); + it("wakes tomorrow and next week at the chosen morning hour", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), 5); + expect(presets.find((preset) => preset.id === "tomorrow")?.snoozedUntil).toBe( + localDate(2026, 4, 9, 5).toISOString(), + ); + expect(presets.find((preset) => preset.id === "next-week")?.snoozedUntil).toBe( + localDate(2026, 4, 13, 5).toISOString(), + ); + }); + it("drops next week on Sundays, when it lands on the same Monday as tomorrow", () => { // Sunday 2026-08-30 07:01: "Tomorrow" and "Next week" are both Monday 9:00. const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1)); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 082459efc3df..e6f78ced9d38 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -45,6 +45,11 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +/** Local hour (0-23) that the "Tomorrow" and "Next week" snooze presets wake at. */ +export const SnoozeMorningHour = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 23 })); +export type SnoozeMorningHour = typeof SnoozeMorningHour.Type; +export const DEFAULT_SNOOZE_MORNING_HOUR: SnoozeMorningHour = 9; + export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; @@ -485,6 +490,9 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + snoozeMorningHour: SnoozeMorningHour.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_MORNING_HOUR)), + ), snapShotEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), snapShotIncludeAccessibility: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), @@ -1795,6 +1803,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), + snoozeMorningHour: Schema.optionalKey(SnoozeMorningHour), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), snapShotShortcut: Schema.optionalKey(SnapShotShortcut), From dde15b6f68fc6f390963641ad4f788824074d7cb Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:08:36 +0000 Subject: [PATCH 2/6] refactor(web): pass the snooze hour to sidebar rows as a prop Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/Sidebar.snooze.test.ts | 41 +++++++++++++++---- apps/web/src/components/Sidebar.snooze.ts | 2 +- apps/web/src/components/Sidebar.tsx | 9 ++-- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index daa10c14d9c3..5cef3d43d424 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_SNOOZE_MORNING_HOUR } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze"; @@ -10,7 +11,11 @@ function localDate(year: number, month: number, day: number, hour: number, minut describe("resolveSnoozePresets", () => { it("offers one hour, three hours, evening, tomorrow, and next week in the morning", () => { // Wednesday 2026-04-08 10:00 local. - const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale"); + const presets = resolveSnoozePresets( + localDate(2026, 4, 8, 10), + "locale", + DEFAULT_SNOOZE_MORNING_HOUR, + ); expect(presets.map((preset) => preset.id)).toEqual([ "hour", "three-hours", @@ -33,7 +38,11 @@ describe("resolveSnoozePresets", () => { }); it("whenLabel complements the label instead of repeating it", () => { - const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale"); + const presets = resolveSnoozePresets( + localDate(2026, 4, 8, 10), + "locale", + DEFAULT_SNOOZE_MORNING_HOUR, + ); for (const preset of presets) { // Day words live in the label column; the time column is time-only // (plus a weekday for next week, which names a different day). @@ -47,16 +56,26 @@ describe("resolveSnoozePresets", () => { it("drops the evening preset once evening is near or past", () => { expect( - resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale").map((preset) => preset.id), + resolveSnoozePresets( + localDate(2026, 4, 8, 17, 30), + "locale", + DEFAULT_SNOOZE_MORNING_HOUR, + ).map((preset) => preset.id), ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); expect( - resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale").map((preset) => preset.id), + resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale", DEFAULT_SNOOZE_MORNING_HOUR).map( + (preset) => preset.id, + ), ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); }); it("puts next week a full week out when today is Monday", () => { // Monday 2026-04-06. - const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale"); + const presets = resolveSnoozePresets( + localDate(2026, 4, 6, 10), + "locale", + DEFAULT_SNOOZE_MORNING_HOUR, + ); const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); expect(nextWeek.getDay()).toBe(1); expect(nextWeek.getDate()).toBe(13); @@ -69,8 +88,16 @@ describe("resolveSnoozePresets", () => { }); it("formats preset times with the selected clock preference", () => { - const twelveHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "12-hour"); - const twentyFourHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour"); + const twelveHour = resolveSnoozePresets( + localDate(2026, 4, 8, 10), + "12-hour", + DEFAULT_SNOOZE_MORNING_HOUR, + ); + const twentyFourHour = resolveSnoozePresets( + localDate(2026, 4, 8, 10), + "24-hour", + DEFAULT_SNOOZE_MORNING_HOUR, + ); expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i); expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00"); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index a85fc1a1467b..42740f66547d 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -18,7 +18,7 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { export function resolveSnoozePresets( now: Date, timestampFormat: TimestampFormat, - morningHour?: SnoozeMorningHour, + morningHour: SnoozeMorningHour, ): ReadonlyArray { return resolveSharedSnoozePresets(now, morningHour).map((preset) => { const wake = parseTimestampDate(preset.snoozedUntil); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c68b2083d3e7..9518f63fc2e5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -57,7 +57,7 @@ import { type ThreadId, } from "@t3tools/contracts"; -import type { TimestampFormat } from "@t3tools/contracts/settings"; +import type { SnoozeMorningHour, TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -547,9 +547,9 @@ function SnoozeMenuButton(props: { onOpenChange: (open: boolean) => void; onSnooze: (preset: Pick) => void; timestampFormat: TimestampFormat; + snoozeMorningHour: SnoozeMorningHour; }) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; - const snoozeMorningHour = useClientSettings((s) => s.snoozeMorningHour); + const { open, onOpenChange, onSnooze, timestampFormat, snoozeMorningHour } = props; // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( @@ -1117,6 +1117,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; + snoozeMorningHour: SnoozeMorningHour; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; @@ -2070,6 +2071,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} timestampFormat={props.timestampFormat} + snoozeMorningHour={props.snoozeMorningHour} /> ) : null} {props.settlementSupported ? ( @@ -5195,6 +5197,7 @@ export default function Sidebar() { EMPTY_PROVIDER_ENTRIES } timestampFormat={timestampFormat} + snoozeMorningHour={snoozeMorningHour} onThreadClick={handleThreadClick} onThreadActivate={navigateToThread} onStartRename={startThreadRename} From b30feea3140c6079975dae86af0e3f19cb62d822 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:12:20 +0000 Subject: [PATCH 3/6] refactor: name the snooze setting after the wake hour it holds Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/Sidebar.snooze.test.ts | 24 ++++++++--------- apps/web/src/components/Sidebar.snooze.ts | 6 ++--- apps/web/src/components/Sidebar.tsx | 26 +++++++++---------- .../settings/SettingsPanels.restore.test.tsx | 4 +-- .../components/settings/SettingsPanels.tsx | 16 ++++++------ apps/web/src/hooks/useThreadActionMenu.ts | 6 ++--- .../client-runtime/src/state/threadSettled.ts | 10 +++---- .../src/state/threadSnoozed.test.ts | 2 +- packages/contracts/src/settings.ts | 12 ++++----- 9 files changed, 52 insertions(+), 54 deletions(-) diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 5cef3d43d424..d1a9062333bf 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_SNOOZE_MORNING_HOUR } from "@t3tools/contracts/settings"; +import { DEFAULT_SNOOZE_WAKE_HOUR } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze"; @@ -14,7 +14,7 @@ describe("resolveSnoozePresets", () => { const presets = resolveSnoozePresets( localDate(2026, 4, 8, 10), "locale", - DEFAULT_SNOOZE_MORNING_HOUR, + DEFAULT_SNOOZE_WAKE_HOUR, ); expect(presets.map((preset) => preset.id)).toEqual([ "hour", @@ -41,7 +41,7 @@ describe("resolveSnoozePresets", () => { const presets = resolveSnoozePresets( localDate(2026, 4, 8, 10), "locale", - DEFAULT_SNOOZE_MORNING_HOUR, + DEFAULT_SNOOZE_WAKE_HOUR, ); for (const preset of presets) { // Day words live in the label column; the time column is time-only @@ -56,14 +56,12 @@ describe("resolveSnoozePresets", () => { it("drops the evening preset once evening is near or past", () => { expect( - resolveSnoozePresets( - localDate(2026, 4, 8, 17, 30), - "locale", - DEFAULT_SNOOZE_MORNING_HOUR, - ).map((preset) => preset.id), + resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale", DEFAULT_SNOOZE_WAKE_HOUR).map( + (preset) => preset.id, + ), ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); expect( - resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale", DEFAULT_SNOOZE_MORNING_HOUR).map( + resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale", DEFAULT_SNOOZE_WAKE_HOUR).map( (preset) => preset.id, ), ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); @@ -74,13 +72,13 @@ describe("resolveSnoozePresets", () => { const presets = resolveSnoozePresets( localDate(2026, 4, 6, 10), "locale", - DEFAULT_SNOOZE_MORNING_HOUR, + DEFAULT_SNOOZE_WAKE_HOUR, ); const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); expect(nextWeek.getDay()).toBe(1); expect(nextWeek.getDate()).toBe(13); }); - it("labels tomorrow and next week with the chosen morning hour", () => { + it("labels tomorrow and next week with the chosen wake hour", () => { const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour", 5); expect(presets.find((preset) => preset.id === "tomorrow")!.whenLabel).toBe("05:00"); @@ -91,12 +89,12 @@ describe("resolveSnoozePresets", () => { const twelveHour = resolveSnoozePresets( localDate(2026, 4, 8, 10), "12-hour", - DEFAULT_SNOOZE_MORNING_HOUR, + DEFAULT_SNOOZE_WAKE_HOUR, ); const twentyFourHour = resolveSnoozePresets( localDate(2026, 4, 8, 10), "24-hour", - DEFAULT_SNOOZE_MORNING_HOUR, + DEFAULT_SNOOZE_WAKE_HOUR, ); expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index 42740f66547d..a3b21f773e65 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -1,4 +1,4 @@ -import type { SnoozeMorningHour, TimestampFormat } from "@t3tools/contracts/settings"; +import type { SnoozeWakeHour, TimestampFormat } from "@t3tools/contracts/settings"; import { resolveSnoozePresets as resolveSharedSnoozePresets, snoozeWakeLabel, @@ -18,9 +18,9 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { export function resolveSnoozePresets( now: Date, timestampFormat: TimestampFormat, - morningHour: SnoozeMorningHour, + wakeHour: SnoozeWakeHour, ): ReadonlyArray { - return resolveSharedSnoozePresets(now, morningHour).map((preset) => { + return resolveSharedSnoozePresets(now, wakeHour).map((preset) => { const wake = parseTimestampDate(preset.snoozedUntil); if (wake === null) return preset; const time = timeOfDayLabel(wake, timestampFormat); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9518f63fc2e5..646d032be651 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -57,7 +57,7 @@ import { type ThreadId, } from "@t3tools/contracts"; -import type { SnoozeMorningHour, TimestampFormat } from "@t3tools/contracts/settings"; +import type { SnoozeWakeHour, TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -547,14 +547,14 @@ function SnoozeMenuButton(props: { onOpenChange: (open: boolean) => void; onSnooze: (preset: Pick) => void; timestampFormat: TimestampFormat; - snoozeMorningHour: SnoozeMorningHour; + snoozeWakeHour: SnoozeWakeHour; }) { - const { open, onOpenChange, onSnooze, timestampFormat, snoozeMorningHour } = props; + const { open, onOpenChange, onSnooze, timestampFormat, snoozeWakeHour } = props; // Presets resolve at open time so "In 1 hour" is relative to the click, // not to when the row mounted. const presets = useMemo( - () => (open ? resolveSnoozePresets(new Date(), timestampFormat, snoozeMorningHour) : []), - [open, timestampFormat, snoozeMorningHour], + () => (open ? resolveSnoozePresets(new Date(), timestampFormat, snoozeWakeHour) : []), + [open, timestampFormat, snoozeWakeHour], ); return ( @@ -1117,7 +1117,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; - snoozeMorningHour: SnoozeMorningHour; + snoozeWakeHour: SnoozeWakeHour; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; @@ -2071,7 +2071,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} timestampFormat={props.timestampFormat} - snoozeMorningHour={props.snoozeMorningHour} + snoozeWakeHour={props.snoozeWakeHour} /> ) : null} {props.settlementSupported ? ( @@ -2335,7 +2335,7 @@ export default function Sidebar() { const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); - const snoozeMorningHour = useClientSettings((s) => s.snoozeMorningHour); + const snoozeWakeHour = useClientSettings((s) => s.snoozeWakeHour); const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { @@ -4205,7 +4205,7 @@ export default function Sidebar() { const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, snoozeMorningHour); + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, snoozeWakeHour); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -4370,7 +4370,7 @@ export default function Sidebar() { settleThreads, updateThreadMetadata, timestampFormat, - snoozeMorningHour, + snoozeWakeHour, ], ); @@ -4461,7 +4461,7 @@ export default function Sidebar() { const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, snoozeMorningHour); + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, snoozeWakeHour); const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( @@ -4709,7 +4709,7 @@ export default function Sidebar() { startThreadRename, updateThreadMetadata, timestampFormat, - snoozeMorningHour, + snoozeWakeHour, ], ); @@ -5197,7 +5197,7 @@ export default function Sidebar() { EMPTY_PROVIDER_ENTRIES } timestampFormat={timestampFormat} - snoozeMorningHour={snoozeMorningHour} + snoozeWakeHour={snoozeWakeHour} onThreadClick={handleThreadClick} onThreadActivate={navigateToThread} onStartRename={startThreadRename} diff --git a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx index 3c88a4d21b83..2b1229e3dd7f 100644 --- a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx +++ b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx @@ -79,14 +79,14 @@ describe("restoring V2 settings", () => { }); it("restores the snooze wake time", async () => { - state.settings = { ...DEFAULT_UNIFIED_SETTINGS, snoozeMorningHour: 5 }; + state.settings = { ...DEFAULT_UNIFIED_SETTINGS, snoozeWakeHour: 5 }; hooks.beginRender(); const restore = useSettingsRestore(); expect(restore.changedSettingLabels).toEqual(["Snooze wake time"]); await restore.restoreDefaults(); - expect(state.update.mock.calls[0]?.[0].snoozeMorningHour).toBe(9); + expect(state.update.mock.calls[0]?.[0].snoozeWakeHour).toBe(9); }); it("does not reset settings after cancellation", async () => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 99009f715d8b..c81bd999d603 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -593,7 +593,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads ? ["Snooze limited threads"] : []), - ...(settings.snoozeMorningHour !== DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour + ...(settings.snoozeWakeHour !== DEFAULT_UNIFIED_SETTINGS.snoozeWakeHour ? ["Snooze wake time"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), @@ -716,7 +716,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, - settings.snoozeMorningHour, + settings.snoozeWakeHour, settings.sidebarProjectGroupingMode, settings.sidebarProjectSortOrder, settings.sidebarWorkingShelfEnabled, @@ -824,7 +824,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, - snoozeMorningHour: DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour, + snoozeWakeHour: DEFAULT_UNIFIED_SETTINGS.snoozeWakeHour, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -2378,12 +2378,12 @@ export function GeneralSettingsPanel() { {...searchableSetting("snooze-wake-time")} description="When threads snoozed until Tomorrow or Next week wake." resetAction={ - settings.snoozeMorningHour !== DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour ? ( + settings.snoozeWakeHour !== DEFAULT_UNIFIED_SETTINGS.snoozeWakeHour ? ( updateSettings({ - snoozeMorningHour: DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour, + snoozeWakeHour: DEFAULT_UNIFIED_SETTINGS.snoozeWakeHour, }) } /> @@ -2391,14 +2391,14 @@ export function GeneralSettingsPanel() { } control={ { + if (value !== null) props.onHourChange(Number(value)); + }} + > + + + {props.label}{" "} + {hourOfDayLabel(props.hour, props.timestampFormat)} + + + + {HOURS_OF_DAY.map((hour) => ( + + {hourOfDayLabel(hour, props.timestampFormat)} + + ))} + + + ); +} + const CHAT_WIDTH_LABELS: Record = { comfortable: "Comfortable", wide: "Wide", @@ -593,9 +637,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads ? ["Snooze limited threads"] : []), - ...(settings.snoozeWakeHour !== DEFAULT_UNIFIED_SETTINGS.snoozeWakeHour - ? ["Snooze wake time"] - : []), + ...(hasChangedSnoozeTimes(settings) ? ["Snooze times"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.persistComposerContextStrip !== DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip @@ -716,7 +758,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, - settings.snoozeWakeHour, + settings.snoozeMorningHour, + settings.snoozeEveningHour, settings.sidebarProjectGroupingMode, settings.sidebarProjectSortOrder, settings.sidebarWorkingShelfEnabled, @@ -824,7 +867,8 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, - snoozeWakeHour: DEFAULT_UNIFIED_SETTINGS.snoozeWakeHour, + snoozeMorningHour: DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour, + snoozeEveningHour: DEFAULT_UNIFIED_SETTINGS.snoozeEveningHour, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -2375,40 +2419,36 @@ export function GeneralSettingsPanel() { } /> updateSettings({ - snoozeWakeHour: DEFAULT_UNIFIED_SETTINGS.snoozeWakeHour, + snoozeMorningHour: DEFAULT_UNIFIED_SETTINGS.snoozeMorningHour, + snoozeEveningHour: DEFAULT_UNIFIED_SETTINGS.snoozeEveningHour, }) } /> ) : null } control={ - +
+ updateSettings({ snoozeMorningHour: hour })} + /> + updateSettings({ snoozeEveningHour: hour })} + /> +
} /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4305c03a428d..948cfc813a30 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -293,10 +293,10 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["usage quota rate limit reset wake recover continue"], }, { - id: "snooze-wake-time", - title: "Snooze wake time", + id: "snooze-times", + title: "Snooze times", to: "/settings/general", - searchTerms: ["remind tomorrow next week morning hour wake up"], + searchTerms: ["remind this evening tomorrow next week morning hour wake up"], }, { id: "auto-resume-limited-threads", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 7bc40b6b0c3d..691264f6c5bc 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -101,7 +101,8 @@ export function useThreadActionMenu(input: { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); - const snoozeWakeHour = useClientSettings((s) => s.snoozeWakeHour); + const snoozeMorningHour = useClientSettings((s) => s.snoozeMorningHour); + const snoozeEveningHour = useClientSettings((s) => s.snoozeEveningHour); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path }); @@ -141,7 +142,10 @@ export function useThreadActionMenu(input: { titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; - const snoozePresets = resolveSnoozePresets(now, timestampFormat, snoozeWakeHour); + const snoozePresets = resolveSnoozePresets(now, timestampFormat, { + morningHour: snoozeMorningHour, + eveningHour: snoozeEveningHour, + }); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, projectFilter: null, @@ -346,7 +350,8 @@ export function useThreadActionMenu(input: { settleThread, snoozeThread, threadRef, - snoozeWakeHour, + snoozeEveningHour, + snoozeMorningHour, timestampFormat, unsettleThread, unsnoozeThread, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index a4e1ac848933..8a3cb0cbb2cf 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -224,5 +224,5 @@ 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 several selected threads together. Choose **Wake thread** to bring a thread back early. -**Tomorrow** and **Next week** wake at 9:00 by default. On web and desktop, change the hour in -**Settings → General → Snooze wake time**. +**This evening** wakes at 18:00, and **Tomorrow** and **Next week** wake at 9:00. On web and +desktop, change both hours in **Settings → General → Snooze times**. diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index afd3cc5ab44d..c99767283f93 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,5 +1,5 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. -import { DEFAULT_SNOOZE_WAKE_HOUR } from "@t3tools/contracts/settings"; +import { DEFAULT_SNOOZE_HOURS, type SnoozeHour } from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; interface SettlementRunLike { @@ -212,7 +212,11 @@ export function threadWokeAt( } const HOUR_MS = 60 * 60 * 1_000; -const EVENING_HOUR = 18; + +export interface SnoozeHours { + readonly morningHour: SnoozeHour; + readonly eveningHour: SnoozeHour; +} export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; @@ -250,12 +254,12 @@ function addSnoozeDays(base: Date, days: number): Date { * appears while it is meaningfully before evening; after that the calendar * choices start at "Tomorrow". Calendar presets that land on the same * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday - * morning, so only "Tomorrow" is offered. Both calendar presets wake at the - * user's `wakeHour`. + * morning, so only "Tomorrow" is offered. "This evening" wakes at the user's + * evening hour; "Tomorrow" and "Next week" wake at their morning hour. */ export function resolveSnoozePresets( now: Date, - wakeHour: number = DEFAULT_SNOOZE_WAKE_HOUR, + { morningHour, eveningHour }: SnoozeHours = DEFAULT_SNOOZE_HOURS, ): ReadonlyArray { const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS)); const inThreeHours = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + 3 * HOUR_MS)); @@ -274,7 +278,7 @@ export function resolveSnoozePresets( }, ]; - const evening = snoozeAtHour(now, EVENING_HOUR); + const evening = snoozeAtHour(now, eveningHour); if (evening.getTime() - now.getTime() > HOUR_MS) { presets.push({ id: "evening", @@ -284,7 +288,7 @@ export function resolveSnoozePresets( }); } - const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), wakeHour); + const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), morningHour); presets.push({ id: "tomorrow", label: "Tomorrow", @@ -293,7 +297,7 @@ export function resolveSnoozePresets( }); const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; - const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), wakeHour); + const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), morningHour); if (nextWeek.getTime() !== tomorrow.getTime()) { presets.push({ id: "next-week", diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 60f41fec087f..e724bfe0413f 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -354,8 +354,11 @@ describe("resolveSnoozePresets", () => { expect(nextWeek.getDate()).toBe(13); }); - it("wakes tomorrow and next week at the chosen wake hour", () => { - const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), 5); + it("wakes tomorrow and next week at the chosen morning hour", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), { + morningHour: 5, + eveningHour: 18, + }); expect(presets.find((preset) => preset.id === "tomorrow")?.snoozedUntil).toBe( localDate(2026, 4, 9, 5).toISOString(), ); @@ -364,6 +367,26 @@ describe("resolveSnoozePresets", () => { ); }); + it("wakes this evening at the chosen evening hour", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), { + morningHour: 9, + eveningHour: 21, + }); + expect(presets.find((preset) => preset.id === "evening")?.snoozedUntil).toBe( + localDate(2026, 4, 8, 21).toISOString(), + ); + }); + + it("offers this evening until an hour before the chosen evening hour", () => { + const hours = { morningHour: 9, eveningHour: 21 }; + expect( + resolveSnoozePresets(localDate(2026, 4, 8, 19, 30), hours).map((preset) => preset.id), + ).toContain("evening"); + expect( + resolveSnoozePresets(localDate(2026, 4, 8, 20, 30), hours).map((preset) => preset.id), + ).not.toContain("evening"); + }); + it("drops next week on Sundays, when it lands on the same Monday as tomorrow", () => { // Sunday 2026-08-30 07:01: "Tomorrow" and "Next week" are both Monday 9:00. const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1)); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f8c61d01acd9..ec4225275f1c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -45,10 +45,14 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; -/** Local hour (0-23) that the "Tomorrow" and "Next week" snooze presets wake at. */ -export const SnoozeWakeHour = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 23 })); -export type SnoozeWakeHour = typeof SnoozeWakeHour.Type; -export const DEFAULT_SNOOZE_WAKE_HOUR: SnoozeWakeHour = 9; +/** Local hour of day (0-23) that a calendar snooze preset wakes at. */ +export const SnoozeHour = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 23 })); +export type SnoozeHour = typeof SnoozeHour.Type; +/** "Tomorrow" and "Next week" wake at the morning hour, "This evening" at the evening hour. */ +export const DEFAULT_SNOOZE_HOURS = { morningHour: 9, eveningHour: 18 } as const satisfies Record< + string, + SnoozeHour +>; export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; @@ -490,8 +494,11 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), - snoozeWakeHour: SnoozeWakeHour.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_WAKE_HOUR)), + snoozeMorningHour: SnoozeHour.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_HOURS.morningHour)), + ), + snoozeEveningHour: SnoozeHour.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_HOURS.eveningHour)), ), snapShotEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), snapShotIncludeAccessibility: Schema.Boolean.pipe( @@ -1803,7 +1810,8 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), - snoozeWakeHour: Schema.optionalKey(SnoozeWakeHour), + snoozeMorningHour: Schema.optionalKey(SnoozeHour), + snoozeEveningHour: Schema.optionalKey(SnoozeHour), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), snapShotShortcut: Schema.optionalKey(SnapShotShortcut), From 91f316dd01849bc22d517b8862bb26fb175333fb Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:44:38 +0000 Subject: [PATCH 5/6] docs: describe snooze hours as defaults Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/user/thread-sidebar.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 8a3cb0cbb2cf..8ce5d84402f8 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -224,5 +224,5 @@ 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 several selected threads together. Choose **Wake thread** to bring a thread back early. -**This evening** wakes at 18:00, and **Tomorrow** and **Next week** wake at 9:00. On web and -desktop, change both hours in **Settings → General → Snooze times**. +By default, **This evening** wakes at 18:00, and **Tomorrow** and **Next week** wake at 9:00. On +web and desktop, change these hours in **Settings → General → Snooze times**. From 5158aa9aab72f0896548ff7416e1fc49ae35f2b2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:02:47 +0000 Subject: [PATCH 6/6] fix(contracts): start the evening snooze hour at noon An evening hour of 0:00 is always past or under an hour away, so "This evening" could never be offered. Both the contract and the Evening picker now offer 12:00 through 23:00. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/Sidebar.tsx | 14 +++++---- .../components/settings/SettingsPanels.tsx | 9 ++++-- .../client-runtime/src/state/threadSettled.ts | 10 +++++-- packages/contracts/src/settings.test.ts | 16 ++++++++++ packages/contracts/src/settings.ts | 30 +++++++++++-------- 5 files changed, 57 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a3b2fab420ce..219844571d90 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -57,7 +57,11 @@ import { type ThreadId, } from "@t3tools/contracts"; -import type { SnoozeHour, TimestampFormat } from "@t3tools/contracts/settings"; +import type { + SnoozeEveningHour, + SnoozeMorningHour, + TimestampFormat, +} from "@t3tools/contracts/settings"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -547,8 +551,8 @@ function SnoozeMenuButton(props: { onOpenChange: (open: boolean) => void; onSnooze: (preset: Pick) => void; timestampFormat: TimestampFormat; - snoozeMorningHour: SnoozeHour; - snoozeEveningHour: SnoozeHour; + snoozeMorningHour: SnoozeMorningHour; + snoozeEveningHour: SnoozeEveningHour; }) { const { open, onOpenChange, onSnooze, timestampFormat, snoozeMorningHour, snoozeEveningHour } = props; @@ -1125,8 +1129,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; - snoozeMorningHour: SnoozeHour; - snoozeEveningHour: SnoozeHour; + snoozeMorningHour: SnoozeMorningHour; + snoozeEveningHour: SnoozeEveningHour; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e9b0429aa894..d1ca7cb4f119 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -43,6 +43,7 @@ import { MIN_PANEL_ANIMATION_DURATION_MS, MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SNOOZE_EVENING_HOUR, type ResponseStreamingMode, MIN_TERMINAL_FONT_SIZE, type QuitConfirmationMode, @@ -199,7 +200,8 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -const HOURS_OF_DAY = Array.from({ length: 24 }, (_, hour) => hour); +const SNOOZE_MORNING_HOURS = Array.from({ length: 24 }, (_, hour) => hour); +const SNOOZE_EVENING_HOURS = SNOOZE_MORNING_HOURS.filter((hour) => hour >= MIN_SNOOZE_EVENING_HOUR); function hourOfDayLabel(hour: number, timestampFormat: TimestampFormat): string { return formatShortTimestamp(new Date(2000, 0, 1, hour).toISOString(), timestampFormat); @@ -216,6 +218,7 @@ function hasChangedSnoozeTimes( function SnoozeHourSelect(props: { label: string; + hours: ReadonlyArray; hour: number; timestampFormat: TimestampFormat; onHourChange: (hour: number) => void; @@ -238,7 +241,7 @@ function SnoozeHourSelect(props: { - {HOURS_OF_DAY.map((hour) => ( + {props.hours.map((hour) => ( {hourOfDayLabel(hour, props.timestampFormat)} @@ -2438,12 +2441,14 @@ export function GeneralSettingsPanel() {
updateSettings({ snoozeMorningHour: hour })} /> updateSettings({ snoozeEveningHour: hour })} diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index c99767283f93..cff79c793c61 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,5 +1,9 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. -import { DEFAULT_SNOOZE_HOURS, type SnoozeHour } from "@t3tools/contracts/settings"; +import { + DEFAULT_SNOOZE_HOURS, + type SnoozeEveningHour, + type SnoozeMorningHour, +} from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; interface SettlementRunLike { @@ -214,8 +218,8 @@ export function threadWokeAt( const HOUR_MS = 60 * 60 * 1_000; export interface SnoozeHours { - readonly morningHour: SnoozeHour; - readonly eveningHour: SnoozeHour; + readonly morningHour: SnoozeMorningHour; + readonly eveningHour: SnoozeEveningHour; } export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index f4a1432f4319..8c0591dc4fb1 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1105,3 +1105,19 @@ describe("branch naming settings", () => { }, ); }); + +describe("ClientSettings snooze hours", () => { + it("accepts evening hours from noon through 23:00", () => { + for (const snoozeEveningHour of [12, 18, 23]) { + expect(decodeClientSettingsPatch({ snoozeEveningHour }).snoozeEveningHour).toBe( + snoozeEveningHour, + ); + } + }); + + it("rejects evening hours before noon", () => { + for (const snoozeEveningHour of [0, 1, 11]) { + expect(() => decodeClientSettingsPatch({ snoozeEveningHour })).toThrow(); + } + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ec4225275f1c..8b1113277b65 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -45,14 +45,20 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; -/** Local hour of day (0-23) that a calendar snooze preset wakes at. */ -export const SnoozeHour = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 23 })); -export type SnoozeHour = typeof SnoozeHour.Type; -/** "Tomorrow" and "Next week" wake at the morning hour, "This evening" at the evening hour. */ -export const DEFAULT_SNOOZE_HOURS = { morningHour: 9, eveningHour: 18 } as const satisfies Record< - string, - SnoozeHour ->; +/** Local hour that the "Tomorrow" and "Next week" snooze presets wake at. */ +export const SnoozeMorningHour = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 23 })); +export type SnoozeMorningHour = typeof SnoozeMorningHour.Type; +/** + * Local hour that the "This evening" snooze preset wakes at. It starts at noon: + * an earlier hour is a morning, and midnight is always past or under an hour + * away, so "This evening" would never be offered. + */ +export const MIN_SNOOZE_EVENING_HOUR = 12; +export const SnoozeEveningHour = Schema.Int.check( + Schema.isBetween({ minimum: MIN_SNOOZE_EVENING_HOUR, maximum: 23 }), +); +export type SnoozeEveningHour = typeof SnoozeEveningHour.Type; +export const DEFAULT_SNOOZE_HOURS = { morningHour: 9, eveningHour: 18 } as const; export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; @@ -494,10 +500,10 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), - snoozeMorningHour: SnoozeHour.pipe( + snoozeMorningHour: SnoozeMorningHour.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_HOURS.morningHour)), ), - snoozeEveningHour: SnoozeHour.pipe( + snoozeEveningHour: SnoozeEveningHour.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNOOZE_HOURS.eveningHour)), ), snapShotEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1810,8 +1816,8 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), - snoozeMorningHour: Schema.optionalKey(SnoozeHour), - snoozeEveningHour: Schema.optionalKey(SnoozeHour), + snoozeMorningHour: Schema.optionalKey(SnoozeMorningHour), + snoozeEveningHour: Schema.optionalKey(SnoozeEveningHour), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), snapShotShortcut: Schema.optionalKey(SnapShotShortcut),