diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6538697da297..acad20df8dd3 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -36,6 +36,7 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; +import { NO_SNOOZE_PRESETS } from "../../state/thread-list-environments"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { @@ -501,6 +502,7 @@ export function HomeScreen(props: HomeScreenProps) { pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, + snoozePresetsByEnvironmentId, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); @@ -722,6 +724,9 @@ export function HomeScreen(props: HomeScreenProps) { snoozed={item.item.snoozed} pinned={item.item.pinned} snoozePresetMinute={item.snoozePresetMinute ?? ""} + savedSnoozePresets={ + snoozePresetsByEnvironmentId.get(thread.environmentId) ?? NO_SNOOZE_PRESETS + } snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} showTrailingDivider={item.showTrailingDivider} @@ -785,6 +790,7 @@ export function HomeScreen(props: HomeScreenProps) { handleRegenerateThreadTitle, handleRenameThread, handleSettleThread, + snoozePresetsByEnvironmentId, handleSnoozeThread, handleUnpinThread, handleUnsnoozeThread, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 27d23b0a0b15..d629b2cfa2ad 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -32,6 +32,7 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; +import { NO_SNOOZE_PRESETS } from "../../state/thread-list-environments"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; @@ -324,6 +325,7 @@ function ThreadNavigationSidebarPane( pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, + snoozePresetsByEnvironmentId, } = listEnvironments; const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); @@ -697,6 +699,9 @@ function ThreadNavigationSidebarPane( snoozed={item.item.snoozed} pinned={item.item.pinned} snoozePresetMinute={item.snoozePresetMinute ?? ""} + savedSnoozePresets={ + snoozePresetsByEnvironmentId.get(thread.environmentId) ?? NO_SNOOZE_PRESETS + } snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} project={projectByKey.get(scopeKey) ?? null} @@ -827,6 +832,7 @@ function ThreadNavigationSidebarPane( showMoreSettled, sidebarScrollGesture, snoozeEnvironmentIds, + snoozePresetsByEnvironmentId, snoozeThread, toggleSettledShelf, toggleSnoozedShelf, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index d8b0e8791b73..918bf3a8a403 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -17,7 +17,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import type { EnvironmentMachineKind, SnoozePresetRule } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; @@ -471,6 +471,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { row's menu offers snooze presets, so those menus refresh while mounted without invalidating every other row. */ readonly snoozePresetMinute: string; + /** The thread environment's saved presets, listed after the built-in snooze choices. */ + readonly savedSnoozePresets: ReadonlyArray; readonly project: EnvironmentProject | null; readonly projectTitle?: string; /** Keep the environment's provider array stable across unrelated list updates. */ @@ -652,8 +654,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { snoozed: snoozedRow, }); const snoozePresets = useMemo( - () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), - [props.snoozePresetMinute, swipeActions.secondary], + () => + swipeActions.secondary === "snooze" + ? resolveSnoozePresets(new Date(), { saved: props.savedSnoozePresets }) + : ([] as const), + [props.savedSnoozePresets, props.snoozePresetMinute, swipeActions.secondary], ); const snoozePresetActions = useMemo( () => [ @@ -827,6 +832,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ event: nativeEvent.event, displayedPresets: snoozePresets, + savedPresets: props.savedSnoozePresets, now: new Date(), }); if (snoozeSelection._tag === "selected") { @@ -851,6 +857,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleUnpin, handleUnsettle, handleUnsnooze, + props.savedSnoozePresets, setCustomSnoozeOpen, snoozePresets, ], diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 7459b17672f9..6247dddc0ef7 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -71,6 +71,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, + savedPresets: [], now: selectedAt, }); @@ -87,6 +88,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { resolveThreadListV2SnoozeMenuSelection({ event: "snooze:evening", displayedPresets, + savedPresets: [], now: new Date(2026, 4, 8, 18, 0, 1), }), ).toEqual({ _tag: "expired" }); @@ -98,6 +100,7 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { const selection = resolveThreadListV2SnoozeMenuSelection({ event: "snooze:hour", displayedPresets, + savedPresets: [], now: selectedAt, }); @@ -108,6 +111,27 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { ); } }); + + it("recomputes a saved preset from the tap time", () => { + const savedPresets = [{ kind: "delay", amount: 3, unit: "days" }] as const; + const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10), { + saved: savedPresets, + }); + const selectedAt = new Date(2026, 4, 8, 10, 30); + const selection = resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:saved:delay:4320m", + displayedPresets, + savedPresets, + now: selectedAt, + }); + + expect(selection._tag).toBe("selected"); + if (selection._tag === "selected") { + expect(selection.preset.snoozedUntil).toBe( + new Date(selectedAt.getTime() + 3 * 24 * 60 * 60 * 1_000).toISOString(), + ); + } + }); }); describe("resolveThreadListV2Status", () => { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f7327706ef57..d288a4570a72 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -23,7 +23,7 @@ import { sortPinnedThreadsByOrderKey, sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, SnoozePresetRule } from "@t3tools/contracts"; import type { ThreadListProvider } from "../../state/thread-list-environments"; import type { ThreadMoveAvailability } from "./threadOrder"; @@ -89,6 +89,7 @@ export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooz export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; + readonly savedPresets: ReadonlyArray; readonly now: Date; }): | { readonly _tag: "selected"; readonly preset: SnoozePreset } @@ -96,7 +97,7 @@ export function resolveThreadListV2SnoozeMenuSelection(input: { | { readonly _tag: "not-snooze" } { if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" }; - const currentPreset = resolveSnoozePresets(input.now).find( + const currentPreset = resolveSnoozePresets(input.now, { saved: input.savedPresets }).find( (candidate) => input.event === `snooze:${candidate.id}`, ); if (currentPreset) return { _tag: "selected", preset: currentPreset }; diff --git a/apps/mobile/src/state/thread-list-environments.test.ts b/apps/mobile/src/state/thread-list-environments.test.ts index fcb31285d5ae..f639bb372371 100644 --- a/apps/mobile/src/state/thread-list-environments.test.ts +++ b/apps/mobile/src/state/thread-list-environments.test.ts @@ -190,6 +190,26 @@ describe("thread list environment projection", () => { } }); + it("shares each environment's snooze presets, stable until they change", () => { + const h = harness(); + try { + const threeDays = { kind: "delay", amount: 3, unit: "days" } as const; + expect(h.read().snoozePresetsByEnvironmentId.get(ID)).toBeUndefined(); + h.write({ ...config, settings: { ...config.settings, snoozePresets: [threeDays] } }); + const withPreset = h.read(); + expect(withPreset.snoozePresetsByEnvironmentId.get(ID)).toEqual([threeDays]); + expect(withPreset.snoozePresetsByEnvironmentId.get(OTHER_ID)).toBeUndefined(); + // A fresh settings broadcast with the same presets keeps row props stable. + h.write({ ...config, settings: { ...config.settings, snoozePresets: [{ ...threeDays }] } }); + expect(h.read()).toBe(withPreset); + h.write(config); + expect(h.read().snoozePresetsByEnvironmentId.get(ID)).toBeUndefined(); + expect(h.notifications().list).toBe(2); + } finally { + h.registry.dispose(); + } + }); + it("restores detected machine kind after removing an override", () => { const h = harness(); try { diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts index 278ce0ec9e23..fadcbaf15d87 100644 --- a/apps/mobile/src/state/thread-list-environments.ts +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -4,7 +4,9 @@ import { type EnvironmentMachineKind, type ServerConfig, type ServerProvider, + type SnoozePresetRule, } from "@t3tools/contracts"; +import { snoozePresetRuleKey } from "@t3tools/client-runtime/state/thread-settled"; import { Atom } from "effect/unstable/reactivity"; export type ThreadListProvider = Pick< @@ -22,6 +24,9 @@ const capabilityKeys = [ "threadTitleRegeneration", ] as const; +/** Rows fall back to this when an environment has no saved snooze presets. */ +export const NO_SNOOZE_PRESETS: ReadonlyArray = []; + function selectEnvironment(config: ServerConfig) { return { providers: config.providers.map( @@ -35,9 +40,20 @@ function selectEnvironment(config: ServerConfig) { ), machineKind: resolveEnvironmentMachineKind(config), capabilities: config.environment.capabilities, + snoozePresets: config.settings.snoozePresets ?? NO_SNOOZE_PRESETS, }; } +function sameSnoozePresets( + left: ReadonlyArray, + right: ReadonlyArray, +) { + return ( + left.length === right.length && + left.every((rule, index) => snoozePresetRuleKey(rule) === snoozePresetRuleKey(right[index]!)) + ); +} + type ListEnvironment = ReturnType; function sameProviders( @@ -69,9 +85,11 @@ function collectEnvironments(environments: ReadonlyMap(); const activeReorderEnvironmentIds = new Set(); const titleRegenerationEnvironmentIds = new Set(); - for (const [id, { providers, machineKind, capabilities }] of environments) { + const snoozePresetsByEnvironmentId = new Map>(); + for (const [id, { providers, machineKind, capabilities, snoozePresets }] of environments) { providersByEnvironmentId.set(id, providers); machineByEnvironmentId.set(id, machineKind); + if (snoozePresets.length > 0) snoozePresetsByEnvironmentId.set(id, snoozePresets); if (capabilities.threadSettlement === true) settlementEnvironmentIds.add(id); if (capabilities.threadSnooze === true) snoozeEnvironmentIds.add(id); if (capabilities.threadAutoSettleOptOut === true) autoSettleOptOutEnvironmentIds.add(id); @@ -90,10 +108,11 @@ function collectEnvironments(environments: ReadonlyMap>, ) { @@ -109,9 +128,13 @@ export function createThreadListEnvironmentsAtom( if (prior && sameProviders(prior.providers, selected.providers)) { selected.providers = prior.providers; } + if (prior && sameSnoozePresets(prior.snoozePresets, selected.snoozePresets)) { + selected.snoozePresets = prior.snoozePresets; + } const unchanged = prior && prior.providers === selected.providers && + prior.snoozePresets === selected.snoozePresets && prior.machineKind === selected.machineKind && capabilityKeys.every( (key) => (prior.capabilities[key] === true) === (selected.capabilities[key] === true), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index ef66b6d5419a..4603be8049c4 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -232,6 +232,7 @@ export const make = Effect.gen(function* () { threadRestartContinuation: true, projectSettingsOverrides: true, threadSnooze: true, + snoozePresets: true, environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 05c685259f73..1f93339e0776 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -541,6 +541,31 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists snooze presets and replaces the list on update", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + const readPersisted = fileSystem + .readFileString(serverConfig.settingsPath) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)))); + const threeDays = { kind: "delay", amount: 3, unit: "days" } as const; + const fridayMorning = { kind: "weekday", weekday: 5, time: "09:00" } as const; + + yield* serverSettings.updateSettings({ snoozePresets: [threeDays, fridayMorning] }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + assert.deepStrictEqual(change?.snoozePresets, [threeDays, fridayMorning]); + assert.deepStrictEqual((yield* readPersisted).snoozePresets, [threeDays, fridayMorning]); + + // Removing a preset sends the shorter list; it must not merge by index. + yield* serverSettings.updateSettings({ snoozePresets: [fridayMorning] }); + assert.deepStrictEqual((yield* readPersisted).snoozePresets, [fridayMorning]); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("persists and broadcasts thread settlement settings", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 16e17e4217eb..56323e8ed36c 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -68,6 +68,16 @@ describe("resolveSnoozePresets", () => { expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i); expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00"); }); + + it("lists saved presets after the built-ins in the selected clock format", () => { + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour", [ + { kind: "weekday", weekday: 5, time: "16:30" }, + ]); + const friday = presets.at(-1)!; + expect(friday.id).toBe("saved:weekday:5:16:30"); + expect(friday.label).toBe("Friday"); + expect(friday.whenLabel).toMatch(/16:30$/); + }); }); describe("snoozeWakeDescription", () => { diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index e7b980279a4b..284f54d38bbe 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 { SnoozePresetRule, TimestampFormat } from "@t3tools/contracts/settings"; import { resolveSnoozePresets as resolveSharedSnoozePresets, snoozeWakeLabel, @@ -15,21 +15,15 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { return formatShortTimestamp(date.toISOString(), timestampFormat); } +/** Built-in and saved Snooze menu choices, with times in the user's clock format. */ export function resolveSnoozePresets( now: Date, timestampFormat: TimestampFormat, + saved: ReadonlyArray = [], ): ReadonlyArray { - 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" - ? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}` - : time, - }; + return resolveSharedSnoozePresets(now, { + saved, + formatTime: (date) => timeOfDayLabel(date, timestampFormat), }); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf862c50f46d..c1c4d6304ec5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -52,6 +52,7 @@ import { scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { + type EnvironmentId, type EnvironmentMachineKind, type ScopedThreadRef, type ThreadId, @@ -148,6 +149,7 @@ import { usePrimaryEnvironmentId, } from "../state/environments"; import { + readEnvironmentSnoozePresets, readThreadShell, useAllEnvironmentProjectSnapshotsReady, useProjects, @@ -546,14 +548,22 @@ function SnoozeMenuButton(props: { open: boolean; onOpenChange: (open: boolean) => void; onSnooze: (preset: Pick) => void; + environmentId: EnvironmentId; timestampFormat: TimestampFormat; }) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; + const { open, onOpenChange, onSnooze, environmentId, timestampFormat } = 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) : []), - [open, timestampFormat], + () => + open + ? resolveSnoozePresets( + new Date(), + timestampFormat, + readEnvironmentSnoozePresets(environmentId), + ) + : [], + [environmentId, open, timestampFormat], ); return ( @@ -2068,6 +2078,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { open={snoozeMenuOpen} onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} + environmentId={thread.environmentId} timestampFormat={props.timestampFormat} /> ) : null} @@ -4201,7 +4212,15 @@ export default function Sidebar() { const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + // Environments normally share presets. Any that differ all appear, and + // the same preset from several environments collapses into one row. + const snoozePresets = resolveSnoozePresets( + new Date(), + timestampFormat, + [...new Set(selectedThreads.map((thread) => thread.environmentId))].flatMap( + (environmentId) => readEnvironmentSnoozePresets(environmentId), + ), + ); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -4456,7 +4475,11 @@ 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, + readEnvironmentSnoozePresets(thread.environmentId), + ); const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( diff --git a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx index 88c9cb1776e3..f9366a4ac20b 100644 --- a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx +++ b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx @@ -78,6 +78,20 @@ describe("restoring V2 settings", () => { expect(state.update.mock.calls[0]?.[0][key]).toBe(DEFAULT_UNIFIED_SETTINGS[key]); }); + it("clears saved snooze presets", async () => { + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + snoozePresets: [{ kind: "delay", amount: 3, unit: "days" }], + }; + hooks.beginRender(); + const restore = useSettingsRestore(); + + expect(restore.changedSettingLabels).toEqual(["Snooze presets"]); + await restore.restoreDefaults(); + + expect(state.update.mock.calls[0]?.[0].snoozePresets).toEqual([]); + }); + 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..2f0ccd1d3980 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,6 +1,7 @@ import { SettingsGroup } from "./SettingsGroup"; import { Spinner } from "~/components/ui/spinner"; import { NotificationSettings } from "./NotificationSettings"; +import { SnoozePresetSettings } from "./SnoozePresetSettings"; import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; @@ -583,6 +584,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoResumeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads ? ["Auto-resume limited threads"] : []), + ...(settings.snoozePresets.length > 0 ? ["Snooze presets"] : []), ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads ? ["Snooze limited threads"] : []), @@ -706,6 +708,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, + settings.snoozePresets, settings.sidebarProjectGroupingMode, settings.sidebarProjectSortOrder, settings.sidebarWorkingShelfEnabled, @@ -813,6 +816,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, + snoozePresets: DEFAULT_UNIFIED_SETTINGS.snoozePresets, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, @@ -2330,6 +2334,7 @@ export function GeneralSettingsPanel() { } /> + = { + minutes: "Minutes", + hours: "Hours", + days: "Days", +}; +const WEEKDAY_LABELS = Object.fromEntries( + SNOOZE_PRESET_WEEKDAYS.map(({ weekday, label }) => [String(weekday), label]), +); + +/** + * Saved Snooze menu choices. They live in the environment's shared settings, + * so every client of the environment, mobile included, lists them after the + * built-in choices. Mobile shows them but has no editor. + */ +export function SnoozePresetSettings() { + const saved = useScopedSettings((settings) => settings.snoozePresets); + const timestampFormat = useScopedSettings((settings) => settings.timestampFormat); + const updateSettings = useUpdateScopedSettings(); + const { connectedEnvironments } = useSettingsScope(); + const [formOpen, setFormOpen] = useState(false); + // Older servers drop the key, so an edit would silently vanish there. + const outdated = connectedEnvironments.some( + (environment) => environment.serverConfig?.environment.capabilities.snoozePresets !== true, + ); + const full = saved.length >= MAX_SNOOZE_PRESETS; + const formatTime = (date: Date) => formatShortTimestamp(date.toISOString(), timestampFormat); + + return ( + 0 ? ( + updateSettings({ snoozePresets: [] })} + /> + ) : null + } + control={ + + } + > + + Add preset + + + { + updateSettings({ snoozePresets: [...saved, rule] }); + setFormOpen(false); + }} + /> + + + } + > + {saved.length > 0 ? ( +
+ {saved.map((rule, index) => { + const key = snoozePresetRuleKey(rule); + const label = describeSnoozePresetRule(rule, formatTime); + return ( +
0 && "border-t border-border/60", + )} + > + {label} + +
+ ); + })} +
+ ) : null} +
+ ); +} + +function SnoozePresetForm(props: { + readonly saved: ReadonlyArray; + readonly onAdd: (rule: SnoozePresetRule) => void; +}) { + const id = useId(); + const [kind, setKind] = useState("delay"); + const [amount, setAmount] = useState("3"); + const [unit, setUnit] = useState("days"); + const [weekday, setWeekday] = useState(5); + const [time, setTime] = useState("09:00"); + const [error, setError] = useState(null); + + return ( +
{ + event.preventDefault(); + const result = parseSnoozePresetDraft( + kind === "delay" ? { kind, amount, unit } : { kind, weekday, time }, + props.saved, + ); + if ("error" in result) { + setError(result.error); + return; + } + props.onAdd(result.rule); + }} + > + { + const value = next[0]; + if (value === "delay" || value === "weekday") setKind(value); + setError(null); + }} + > + After a delay + On a weekday + + {kind === "delay" ? ( +
+ {/* No clamping: a silently corrected amount would be validated as a + different preset than the one on screen. parseSnoozePresetDraft + explains out-of-range input instead. */} + { + setAmount(value === null ? "" : String(value)); + setError(null); + }} + > + + + + + + + + +
+ ) : ( +
+ + +
+ )} +

+ {kind === "delay" + ? "Counts from when you snooze. A day is 24 hours." + : "The next one after today, never today itself."} +

+ {error ? ( +

+ {error} +

+ ) : null} + +
+ ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index ae55a327d163..4d0a654ba429 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -286,6 +286,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["sort projects sidebar manual created recent"], }, + { + id: "snooze-presets", + title: "Snooze presets", + to: "/settings/general", + searchTerms: ["snooze menu choices quick later delay days weekday custom remind"], + }, { id: "snooze-limited-threads", title: "Snooze limited threads", diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 793ce7e974e1..e3d957c66ca6 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -20,6 +20,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { + readEnvironmentSnoozePresets, readEnvironmentSupportsAutoSettleOptOut, readEnvironmentSupportsPinning, readEnvironmentSupportsSettlement, @@ -140,7 +141,11 @@ export function useThreadActionMenu(input: { titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; - const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const snoozePresets = resolveSnoozePresets( + now, + timestampFormat, + readEnvironmentSnoozePresets(threadRef.environmentId), + ); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, projectFilter: null, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index a21e5e5c4c4b..e9b284fd36e1 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -10,7 +10,11 @@ import { type ThreadHistoryMeta, } from "@t3tools/client-runtime/state/threads"; import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts"; -import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; +import type { + EnvironmentId, + OrchestrationV2ProjectedTurnItem, + SnoozePresetRule, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentProjects } from "./projects"; @@ -254,6 +258,18 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo ); } +const NO_SNOOZE_PRESETS: ReadonlyArray = []; + +/** The environment's saved Snooze menu choices; none on servers that predate them. */ +export function readEnvironmentSnoozePresets( + environmentId: EnvironmentId, +): ReadonlyArray { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.settings.snoozePresets ?? + NO_SNOOZE_PRESETS + ); +} + /** Whether the environment's server understands thread.visit/mark-unread and projects lastVisitedAt on thread shells. Same version-skew contract as settlement: against older servers, clients keep the browser-local visited diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index eb7c4e6a9ffd..c4e88a21fedb 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -223,3 +223,12 @@ 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. + +To keep a choice you use often in the Snooze menu, add a snooze preset in +**Settings → General** on web or desktop. A preset is either a delay, such as +3 days, or a weekday at a time, such as Friday at 9:00 AM. Delays work like +Custom… durations. A weekday preset means the next one after today, so on a +Friday, Friday means next week. Presets are saved on the environment, so the +mobile app offers them too, in the time zone of the device you snooze from. They +follow the built-in choices, and a choice that lands at the same time as another +appears once. diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 6f94fe0f9037..b395651b050d 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -235,6 +235,19 @@ describe("filterSharedServerPatch", () => { ); }, ); + + it("shares snooze presets only with servers that persist them", () => { + const patch = { + snoozePresets: [{ kind: "delay", amount: 3, unit: "days" }] as const, + sidebarAutoSettleAfterDays: 7, + }; + expect(filterSharedServerPatch(patch, { snoozePresets: true })).toEqual(patch); + // An older server would silently drop the key and then read as a mismatch. + expect(filterSharedServerPatch(patch, {})).toEqual({ sidebarAutoSettleAfterDays: 7 }); + expect(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, {})).not.toHaveProperty( + "snoozePresets", + ); + }); }); describe("findSharedSettingsMismatches", () => { diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 1d6fb5e14ca7..56245e779251 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -2,11 +2,12 @@ * Shared server settings. * * Every server keeps its own `settings.json`, but some keys are user - * preferences that only live on the server because the server has to act on - * them (auto-settlement runs with no client attached). A user does not want - * those to differ per machine. Clients write these keys to every shared-settings - * sync target, and warn when another target still holds a different value so - * the user can push their current value out. + * preferences that live on the server either because the server has to act on + * them (auto-settlement runs with no client attached) or because every client + * of the environment should agree (snooze presets reach mobile this way). A + * user does not want those to differ per machine. Clients write these keys to + * every shared-settings sync target, and warn when another target still holds + * a different value so the user can push their current value out. */ import type { EnvironmentId, @@ -27,6 +28,7 @@ const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleOnMerge", "autoResumeLimitedThreads", "snoozeLimitedThreads", + "snoozePresets", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", "textGenerationModelSelection", @@ -36,6 +38,11 @@ export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number] const SHARED_KEY_SET = new Set(SHARED_SERVER_SETTING_KEYS); +type SharedSettingsCapabilities = Pick< + ExecutionEnvironmentCapabilities, + "threadRestartContinuation" | "snoozePresets" +>; + /** Split a server patch into the keys every environment should receive and the primary-only rest. */ export function splitSharedServerPatch(patch: ServerSettingsPatch): { sharedPatch: ServerSettingsPatch; @@ -59,7 +66,7 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { /** Filter unsupported preferences; direct model writes retain the server's fallback behavior. */ export function filterSharedServerPatch( patch: ServerSettingsPatch, - capabilities: Pick | undefined, + capabilities: SharedSettingsCapabilities | undefined, settings?: ServerSettings, sourceSettings = settings, targetIsSource = false, @@ -81,15 +88,16 @@ export function filterSharedServerPatch( ) { patch = Struct.omit(patch, ["textGenerationModelSelection"]); } - return capabilities?.threadRestartContinuation === true - ? patch - : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); + if (capabilities?.threadRestartContinuation !== true) { + patch = Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); + } + return capabilities?.snoozePresets === true ? patch : Struct.omit(patch, ["snoozePresets"]); } /** The shared subset supported by one environment. */ export function pickSharedServerSettings( settings: ServerSettings, - capabilities?: Pick, + capabilities?: SharedSettingsCapabilities, ): ServerSettingsPatch { return filterSharedServerPatch( Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), @@ -121,9 +129,7 @@ export interface SharedSettingsEnvironment { readonly label: string; readonly syncEligible: boolean; readonly settings: ServerSettings | null; - readonly capabilities?: - | Pick - | undefined; + readonly capabilities?: SharedSettingsCapabilities | undefined; } /** @@ -137,9 +143,7 @@ export interface SharedSettingsEnvironment { export function findSharedSettingsMismatches(input: { readonly primaryEnvironmentId: EnvironmentId | null; readonly primarySettings: ServerSettings | null; - readonly primaryCapabilities?: - | Pick - | undefined; + readonly primaryCapabilities?: SharedSettingsCapabilities | undefined; readonly environments: ReadonlyArray; }): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { if (input.primaryEnvironmentId === null || input.primarySettings === null) { diff --git a/packages/client-runtime/src/state/snoozePresets.test.ts b/packages/client-runtime/src/state/snoozePresets.test.ts new file mode 100644 index 000000000000..4a733ae6f6ba --- /dev/null +++ b/packages/client-runtime/src/state/snoozePresets.test.ts @@ -0,0 +1,179 @@ +// @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze presets. +import type { SnoozePresetRule } from "@t3tools/contracts/settings"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + describeSnoozePresetRule, + localSnoozeTime, + parseSnoozePresetDraft, + resolveSnoozePresetRule, + resolveSnoozePresets, +} from "./threadSettled.ts"; + +const DAY_MS = 24 * 60 * 60 * 1_000; +// Wednesday 2026-04-08 10:00 local. +const WEDNESDAY = new Date(2026, 3, 8, 10); +const IN_THREE_DAYS: SnoozePresetRule = { kind: "delay", amount: 3, unit: "days" }; +const FRIDAY_MORNING: SnoozePresetRule = { kind: "weekday", weekday: 5, time: "09:00" }; + +const weekday = (date: Date) => date.toLocaleDateString(undefined, { weekday: "short" }); + +afterEach(() => vi.unstubAllEnvs()); + +describe("saved snooze presets", () => { + it("leave the built-in choices alone when nothing is saved", () => { + expect(resolveSnoozePresets(WEDNESDAY, { saved: [] })).toEqual(resolveSnoozePresets(WEDNESDAY)); + }); + + it("follow the built-ins, soonest first, with the day in the time column", () => { + const presets = resolveSnoozePresets(WEDNESDAY, { + saved: [IN_THREE_DAYS, FRIDAY_MORNING], + formatTime: localSnoozeTime, + }); + expect(presets.map((preset) => preset.id)).toEqual([ + "hour", + "three-hours", + "evening", + "tomorrow", + "next-week", + "saved:weekday:5:09:00", + "saved:delay:4320m", + ]); + const friday = presets.at(-2)!; + expect(friday.label).toBe("Friday"); + expect(friday.snoozedUntil).toBe(new Date(2026, 3, 10, 9).toISOString()); + expect(friday.whenLabel).toBe(`${weekday(new Date(2026, 3, 10))} 09:00`); + const threeDays = presets.at(-1)!; + expect(threeDays.label).toBe("In 3 days"); + expect(threeDays.snoozedUntil).toBe(new Date(WEDNESDAY.getTime() + 3 * DAY_MS).toISOString()); + }); + + it("put a weekday preset a full week out on that same weekday, and say so", () => { + // Friday 2026-04-10 07:00: "Friday 9:00" is next Friday, not today. + const presets = resolveSnoozePresets(new Date(2026, 3, 10, 7), { + saved: [FRIDAY_MORNING], + formatTime: localSnoozeTime, + }); + const friday = presets.find((preset) => preset.id === "saved:weekday:5:09:00")!; + expect(friday.snoozedUntil).toBe(new Date(2026, 3, 17, 9).toISOString()); + expect(friday.whenLabel).not.toContain(weekday(new Date(2026, 3, 17))); + expect(friday.whenLabel).toMatch(/17/); + }); + + it("collapse into an earlier choice that lands on the same instant", () => { + const sameAsBuiltIns: SnoozePresetRule[] = [ + { kind: "delay", amount: 60, unit: "minutes" }, + { kind: "weekday", weekday: 1, time: "09:00" }, + ]; + expect( + resolveSnoozePresets(WEDNESDAY, { saved: sameAsBuiltIns }).map((preset) => preset.id), + ).toEqual(["hour", "three-hours", "evening", "tomorrow", "next-week"]); + // Monday 2026-04-06: a saved "Tuesday 9:00" is just Tomorrow. + expect( + resolveSnoozePresets(new Date(2026, 3, 6, 10), { + saved: [{ kind: "weekday", weekday: 2, time: "09:00" }], + }).map((preset) => preset.id), + ).toEqual(["hour", "three-hours", "evening", "tomorrow", "next-week"]); + }); + + it("format built-in and saved times with the caller's clock", () => { + const presets = resolveSnoozePresets(WEDNESDAY, { + saved: [IN_THREE_DAYS], + formatTime: () => "TIME", + }); + expect(presets.find((preset) => preset.id === "evening")!.whenLabel).toBe("TIME"); + expect(presets.find((preset) => preset.id === "next-week")!.whenLabel).toMatch(/ TIME$/); + expect(presets.find((preset) => preset.id === "saved:delay:4320m")!.whenLabel).toMatch( + / TIME$/, + ); + }); +}); + +describe("resolveSnoozePresetRule across DST", () => { + it("counts a delay day as 24 elapsed hours", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const before = new Date(2027, 2, 13, 12); + const wake = resolveSnoozePresetRule({ kind: "delay", amount: 1, unit: "days" }, before); + expect(wake.getTime() - before.getTime()).toBe(DAY_MS); + expect(wake.getHours()).toBe(13); + }); + + it("rolls a weekday time skipped by spring forward to the next valid time", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const wake = resolveSnoozePresetRule( + { kind: "weekday", weekday: 0, time: "02:30" }, + new Date("2027-03-10T20:00:00Z"), + ); + expect(wake.toISOString()).toBe("2027-03-14T10:30:00.000Z"); + expect(localSnoozeTime(wake)).toBe("03:30"); + }); + + it("resolves a weekday time repeated by fall back to its first occurrence", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const wake = resolveSnoozePresetRule( + { kind: "weekday", weekday: 0, time: "01:30" }, + new Date("2027-11-03T20:00:00Z"), + ); + expect(wake.toISOString()).toBe("2027-11-07T08:30:00.000Z"); + }); + + it("keeps the wall-clock time for a weekday after the change", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + const wake = resolveSnoozePresetRule(FRIDAY_MORNING, new Date(2027, 2, 13, 12)); + expect(wake.getDay()).toBe(5); + expect(localSnoozeTime(wake)).toBe("09:00"); + }); +}); + +describe("parseSnoozePresetDraft", () => { + it("accepts whole-number delays and weekday times", () => { + expect(parseSnoozePresetDraft({ kind: "delay", amount: "3", unit: "days" }, [])).toEqual({ + rule: IN_THREE_DAYS, + }); + expect(parseSnoozePresetDraft({ kind: "weekday", weekday: 5, time: "09:00" }, [])).toEqual({ + rule: FRIDAY_MORNING, + }); + }); + + it.each(["", "0", "-1", "1.5", "1000", "abc"])("rejects the delay amount %j", (amount) => { + expect(parseSnoozePresetDraft({ kind: "delay", amount, unit: "hours" }, [])).toHaveProperty( + "error", + ); + }); + + it("refuses rules that always duplicate a built-in choice", () => { + for (const draft of [ + { kind: "delay", amount: "60", unit: "minutes" }, + { kind: "delay", amount: "3", unit: "hours" }, + { kind: "weekday", weekday: 1, time: "09:00" }, + ] as const) { + expect(parseSnoozePresetDraft(draft, [])).toEqual({ + error: "That is already a built-in choice.", + }); + } + }); + + it("refuses an equivalent of a saved preset and a full list", () => { + expect( + parseSnoozePresetDraft({ kind: "delay", amount: "72", unit: "hours" }, [IN_THREE_DAYS]), + ).toEqual({ error: "That preset is already saved." }); + const full = Array.from({ length: 8 }, (_, index): SnoozePresetRule => ({ + kind: "delay", + amount: index + 10, + unit: "days", + })); + expect( + parseSnoozePresetDraft({ kind: "delay", amount: "2", unit: "days" }, full), + ).toHaveProperty("error"); + }); +}); + +describe("describeSnoozePresetRule", () => { + it("names the rule for the settings list", () => { + expect(describeSnoozePresetRule({ kind: "delay", amount: 1, unit: "days" })).toBe("In 1 day"); + expect(describeSnoozePresetRule({ kind: "delay", amount: 45, unit: "minutes" })).toBe( + "In 45 minutes", + ); + expect(describeSnoozePresetRule(FRIDAY_MORNING, localSnoozeTime)).toBe("Friday at 09:00"); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 104414a9201d..ed676692bcb3 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,5 +1,12 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. +import { + MAX_SNOOZE_PRESET_AMOUNT, + MAX_SNOOZE_PRESETS, + SnoozePresetRule, + type SnoozePresetUnit, +} from "@t3tools/contracts/settings"; import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; interface SettlementRunLike { readonly turnId?: unknown; @@ -213,23 +220,68 @@ export function threadWokeAt( const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; +const SNOOZE_UNIT_MS: Record = { + minutes: 60_000, + hours: HOUR_MS, + days: DAY_MS, +}; +const WEEKDAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; +// The schema bounds weekdays to 0-6; the fallback only satisfies the index type. +function weekdayName(weekday: number): string { + return WEEKDAY_NAMES[weekday] ?? ""; +} +/** Weekday choices for preset forms, Monday first. */ +export const SNOOZE_PRESET_WEEKDAYS = ([1, 2, 3, 4, 5, 6, 0] as const).map((weekday) => ({ + weekday, + label: WEEKDAY_NAMES[weekday], +})); -export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; +type BuiltInSnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; +/** A built-in choice, or `saved:` for a preset saved in settings. */ +export type SnoozePresetId = BuiltInSnoozePresetId | `saved:${string}`; export interface SnoozePreset { readonly id: SnoozePresetId; readonly label: string; - /** Menu-row time column. Complements the label instead of repeating it: - "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ + /** Menu-row time column. Built-ins complement the label instead of + repeating it: "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". + Saved presets name a rule, so their column carries the day. */ readonly whenLabel: string; /** ISO wake time. */ readonly snoozedUntil: string; } +export interface SnoozePresetOptions { + /** Presets saved in settings, listed after the built-in choices. */ + readonly saved?: ReadonlyArray; + /** Formats a time of day. Web passes the user's clock preference. */ + readonly formatTime?: (date: Date) => string; +} + +// The built-in choices that are plain rules. Saving a rule with one of these +// keys would only add a duplicate, so settings refuse it. +const BUILT_IN_SNOOZE_RULES = { + hour: { kind: "delay", amount: 1, unit: "hours" }, + "three-hours": { kind: "delay", amount: 3, unit: "hours" }, + "next-week": { kind: "weekday", weekday: 1, time: "09:00" }, +} as const satisfies Partial>; + function snoozeTimeOfDayLabel(date: Date): string { return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } +function snoozeWeekdayLabel(date: Date): string { + return date.toLocaleDateString(undefined, { weekday: "short" }); +} + function snoozeAtHour(base: Date, hour: number): Date { const next = DateTime.toDate(DateTime.makeUnsafe(base)); next.setHours(hour, 0, 0, 0); @@ -245,61 +297,171 @@ function addSnoozeDays(base: Date, days: number): Date { return next; } +function parseSnoozePresetTime(time: string): { readonly hours: number; readonly minutes: number } { + const [hours = 0, minutes = 0] = time.split(":").map(Number); + return { hours, minutes }; +} + +/** + * Wake time for a preset rule, in the device's local time zone. A delay is + * elapsed time, so a day is 24 hours even across a DST change, matching + * Custom… durations. A weekday is the next such day after today at that + * wall-clock time; a time skipped by a DST change rolls forward to the next + * valid one, and a repeated time resolves to its first occurrence. + */ +export function resolveSnoozePresetRule(rule: SnoozePresetRule, now: Date): Date { + if (rule.kind === "delay") { + return DateTime.toDate( + DateTime.makeUnsafe(now.getTime() + rule.amount * SNOOZE_UNIT_MS[rule.unit]), + ); + } + // Never today: on a Friday, "Friday" means next week's, like "Next week" on + // a Monday. Today's later hours belong to the delay presets. + const daysAhead = (rule.weekday - now.getDay() + 7) % 7 || 7; + const wake = addSnoozeDays(now, daysAhead); + const { hours, minutes } = parseSnoozePresetTime(rule.time); + wake.setHours(hours, minutes, 0, 0); + return wake; +} + +/** Rules with the same key always resolve to the same wake time. */ +export function snoozePresetRuleKey(rule: SnoozePresetRule): string { + return rule.kind === "delay" + ? `delay:${(rule.amount * SNOOZE_UNIT_MS[rule.unit]) / 60_000}m` + : `weekday:${rule.weekday}:${rule.time}`; +} + +function delayLabel(rule: Extract): string { + const unit = rule.amount === 1 ? rule.unit.slice(0, -1) : rule.unit; + return `In ${rule.amount} ${unit}`; +} + +/** How settings list a saved rule: "In 3 days", "Friday at 9:00 AM". */ +export function describeSnoozePresetRule( + rule: SnoozePresetRule, + formatTime: (date: Date) => string = snoozeTimeOfDayLabel, +): string { + if (rule.kind === "delay") return delayLabel(rule); + const { hours, minutes } = parseSnoozePresetTime(rule.time); + // Any date works: only the time of day is formatted. + const time = formatTime(new Date(2000, 0, 1, hours, minutes)); + return `${weekdayName(rule.weekday)} at ${time}`; +} + +// Saved presets name a rule rather than a day, so their time column carries +// the day: "3:45 PM" today, "Fri 9:00 AM" this week, "Oct 16, 9:00 AM" when +// a weekday preset lands a full week out. +function savedPresetWhenLabel(wake: Date, now: Date, formatTime: (date: Date) => string): string { + const time = formatTime(wake); + const dayDelta = Math.round( + (snoozeAtHour(wake, 0).getTime() - snoozeAtHour(now, 0).getTime()) / DAY_MS, + ); + if (dayDelta === 0) return time; + if (dayDelta < 7) return `${snoozeWeekdayLabel(wake)} ${time}`; + return `${wake.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${time}`; +} + /** * Shared "snooze until" choices for every client. "This evening" only * 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. + * choices start at "Tomorrow". Saved presets follow the built-ins, soonest + * first. A choice that lands on the same instant as an earlier one collapses + * into it: on Sundays "Next week" is Monday morning just like "Tomorrow", and + * a saved "Monday at 9:00 AM" is always one of the two. */ -export function resolveSnoozePresets(now: Date): ReadonlyArray { - const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS)); - const inThreeHours = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + 3 * HOUR_MS)); +export function resolveSnoozePresets( + now: Date, + options: SnoozePresetOptions = {}, +): ReadonlyArray { + const formatTime = options.formatTime ?? snoozeTimeOfDayLabel; + const preset = ( + id: SnoozePresetId, + label: string, + wake: Date, + whenLabel = formatTime(wake), + ): SnoozePreset => ({ id, label, whenLabel, snoozedUntil: wake.toISOString() }); + const presets: SnoozePreset[] = [ - { - id: "hour", - label: "In 1 hour", - whenLabel: snoozeTimeOfDayLabel(inAnHour), - snoozedUntil: inAnHour.toISOString(), - }, - { - id: "three-hours", - label: "In 3 hours", - whenLabel: snoozeTimeOfDayLabel(inThreeHours), - snoozedUntil: inThreeHours.toISOString(), - }, + preset("hour", "In 1 hour", resolveSnoozePresetRule(BUILT_IN_SNOOZE_RULES.hour, now)), + preset( + "three-hours", + "In 3 hours", + resolveSnoozePresetRule(BUILT_IN_SNOOZE_RULES["three-hours"], now), + ), ]; - const evening = snoozeAtHour(now, EVENING_HOUR); if (evening.getTime() - now.getTime() > HOUR_MS) { - presets.push({ - id: "evening", - label: "This evening", - whenLabel: snoozeTimeOfDayLabel(evening), - snoozedUntil: evening.toISOString(), - }); + presets.push(preset("evening", "This evening", evening)); + } + presets.push(preset("tomorrow", "Tomorrow", snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR))); + const nextWeek = resolveSnoozePresetRule(BUILT_IN_SNOOZE_RULES["next-week"], now); + presets.push( + preset( + "next-week", + "Next week", + nextWeek, + `${snoozeWeekdayLabel(nextWeek)} ${formatTime(nextWeek)}`, + ), + ); + + const saved = (options.saved ?? []) + .map((rule) => ({ rule, wake: resolveSnoozePresetRule(rule, now) })) + .sort((left, right) => left.wake.getTime() - right.wake.getTime()); + for (const { rule, wake } of saved) { + presets.push( + preset( + `saved:${snoozePresetRuleKey(rule)}`, + rule.kind === "delay" ? delayLabel(rule) : weekdayName(rule.weekday), + wake, + savedPresetWhenLabel(wake, now, formatTime), + ), + ); } - const tomorrow = snoozeAtHour(addSnoozeDays(now, 1), MORNING_HOUR); - presets.push({ - id: "tomorrow", - label: "Tomorrow", - whenLabel: snoozeTimeOfDayLabel(tomorrow), - snoozedUntil: tomorrow.toISOString(), + const seenWakeTimes = new Set(); + return presets.filter((candidate) => { + if (seenWakeTimes.has(candidate.snoozedUntil)) return false; + seenWakeTimes.add(candidate.snoozedUntil); + return true; }); +} - const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; - const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); - if (nextWeek.getTime() !== tomorrow.getTime()) { - presets.push({ - id: "next-week", - label: "Next week", - whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(nextWeek)}`, - snoozedUntil: nextWeek.toISOString(), - }); - } +/** Form input for a new saved preset, before validation. */ +export type SnoozePresetDraft = + | { readonly kind: "delay"; readonly amount: string; readonly unit: SnoozePresetUnit } + | { readonly kind: "weekday"; readonly weekday: number; readonly time: string }; + +const isSnoozePresetRule = Schema.is(SnoozePresetRule); - return presets; +/** + * Validates a new saved preset against the settings schema and the presets + * already saved. A rule that always duplicates a built-in or saved choice is + * refused here; ones that only coincide on some days collapse in the menu. + */ +export function parseSnoozePresetDraft( + draft: SnoozePresetDraft, + saved: ReadonlyArray, +): { readonly rule: SnoozePresetRule } | { readonly error: string } { + if (saved.length >= MAX_SNOOZE_PRESETS) { + return { error: `You can save up to ${MAX_SNOOZE_PRESETS} presets. Remove one first.` }; + } + const candidate = draft.kind === "delay" ? { ...draft, amount: Number(draft.amount) } : draft; + if (!isSnoozePresetRule(candidate)) { + return { + error: + draft.kind === "delay" + ? `Enter a whole number from 1 to ${MAX_SNOOZE_PRESET_AMOUNT}.` + : "Choose a day and a time.", + }; + } + const key = snoozePresetRuleKey(candidate); + if (Object.values(BUILT_IN_SNOOZE_RULES).some((rule) => snoozePresetRuleKey(rule) === key)) { + return { error: "That is already a built-in choice." }; + } + if (saved.some((rule) => snoozePresetRuleKey(rule) === key)) { + return { error: "That preset is already saved." }; + } + return { rule: candidate }; } /** @@ -323,7 +485,7 @@ export type CustomSnoozeInput = | { readonly mode: "duration"; readonly amount: string; - readonly unit: "minutes" | "hours" | "days"; + readonly unit: SnoozePresetUnit; }; /** Resolve local calendar input or elapsed time, rejecting past and invalid dates. */ @@ -332,8 +494,7 @@ export function resolveCustomSnooze(input: CustomSnoozeInput, now: Date): string if (input.mode === "duration") { const amount = Number(input.amount); if (!Number.isFinite(amount) || amount <= 0) return null; - const unitMs = { minutes: 60_000, hours: HOUR_MS, days: 24 * HOUR_MS }[input.unit]; - wake = new Date(now.getTime() + amount * unitMs); + wake = new Date(now.getTime() + amount * SNOOZE_UNIT_MS[input.unit]); } else { if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date) || !/^\d{2}:\d{2}$/.test(input.time)) return null; wake = new Date(`${input.date}T${input.time}:00`); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 0fdbbe6d9fd6..3c8ed3dafd81 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -133,6 +133,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server persists the `snoozePresets` setting. Older servers drop the + key, so clients keep the editor read-only for them. */ + snoozePresets: Schema.optionalKey(Schema.Boolean), /** Server streams themes an environment publishes. Absent on servers from before environment themes shipped, which never emit the events -- so a client reconnecting to one must drop published themes rather than keep diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index f4a1432f4319..5696908ab99e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -696,6 +696,39 @@ describe("ClientSettings composer collapse", () => { }); }); +describe("ServerSettings snooze presets", () => { + it("defaults to no saved presets and round-trips both kinds", () => { + expect(decodeServerSettings({}).snoozePresets).toEqual([]); + const snoozePresets = [ + { kind: "delay", amount: 3, unit: "days" }, + { kind: "weekday", weekday: 1, time: "09:00" }, + ]; + expect(decodeServerSettings({ snoozePresets }).snoozePresets).toEqual(snoozePresets); + expect(decodeServerSettingsPatch({ snoozePresets }).snoozePresets).toEqual(snoozePresets); + }); + + it("drops entries this build cannot read instead of failing the settings", () => { + const decoded = decodeServerSettings({ + snoozeLimitedThreads: true, + snoozePresets: [ + { kind: "delay", amount: 3, unit: "days" }, + { kind: "later-today", time: "16:00" }, + { kind: "weekday", weekday: 7, time: "09:00" }, + { kind: "weekday", weekday: 5, time: "9:00" }, + { kind: "delay", amount: 1.5, unit: "hours" }, + ], + }); + expect(decoded.snoozeLimitedThreads).toBe(true); + expect(decoded.snoozePresets).toEqual([{ kind: "delay", amount: 3, unit: "days" }]); + }); + + it("rejects invalid presets in a patch", () => { + expect(() => + decodeServerSettingsPatch({ snoozePresets: [{ kind: "delay", amount: 0, unit: "days" }] }), + ).toThrow(); + }); +}); + describe("ServerSettings thread settlement", () => { it("defaults merge settlement on and inactivity settlement to three days", () => { const settings = decodeServerSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 082459efc3df..6ffe27a2b244 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { + ForwardCompatibleArray, ForwardCompatibleNullable, ForwardCompatibleOptional, OmittedWhenNull, @@ -1193,6 +1194,34 @@ export const StorageCleanupSettings = Schema.Struct({ }); export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; +/** + * A saved Snooze menu choice. The rule is stored on the environment; each + * client resolves it in its own local time zone when the menu opens: + * - `delay`: elapsed time from the moment you choose it; a day is 24 hours, + * matching Custom… durations. + * - `weekday`: the next such weekday after today (never today) at a local + * wall-clock time. Weekday 0 is Sunday. + */ +export const SnoozePresetUnit = Schema.Literals(["minutes", "hours", "days"]); +export type SnoozePresetUnit = typeof SnoozePresetUnit.Type; +export const MAX_SNOOZE_PRESET_AMOUNT = 999; +export const SnoozePresetRule = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("delay"), + amount: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_SNOOZE_PRESET_AMOUNT })), + unit: SnoozePresetUnit, + }), + Schema.Struct({ + kind: Schema.Literal("weekday"), + weekday: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 6 })), + /** 24-hour local "HH:MM". */ + time: Schema.String.check(Schema.isPattern(/^([01]\d|2[0-3]):[0-5]\d$/)), + }), +]); +export type SnoozePresetRule = typeof SnoozePresetRule.Type; +/** Keeps the Snooze menu scannable: built-ins plus this many saved choices. */ +export const MAX_SNOOZE_PRESETS = 8; + export const ServerSettings = Schema.Struct({ worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))), storageCleanup: StorageCleanupSettings.pipe( @@ -1273,6 +1302,12 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Saved Snooze menu choices, listed after the built-in ones on every client + // of the environment. Entries from a newer build that this one cannot read + // are dropped rather than failing the whole settings file. + snoozePresets: ForwardCompatibleArray(SnoozePresetRule).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, @@ -1636,6 +1671,7 @@ export const ServerSettingsPatch = Schema.Struct({ sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), + snoozePresets: Schema.optionalKey(Schema.Array(SnoozePresetRule)), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)),