From 27d4d1a7edad04b240e55e6a85936c34da5dc4ef Mon Sep 17 00:00:00 2001 From: id0sch Date: Sat, 3 Oct 2026 06:20:40 +0000 Subject: [PATCH] feat(web): choose the first day of the week MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a First day of the week setting (System default, Sunday, Monday). It decides where the "Next week" snooze lands and which day the Custom… snooze calendar starts on. System default keeps today's behavior: the calendar follows the locale and "Next week" stays on Monday. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/CustomSnoozeDialog.tsx | 4 +- apps/web/src/components/Sidebar.snooze.ts | 9 ++- apps/web/src/components/Sidebar.tsx | 6 +- .../components/settings/SettingsPanels.tsx | 57 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++ .../components/sidebar/SidebarThreadRow.tsx | 9 ++- apps/web/src/hooks/useThreadActionMenu.ts | 3 +- apps/web/src/timestampFormat.ts | 22 ++++++- docs/user/thread-sidebar.md | 4 ++ .../client-runtime/src/state/threadSettled.ts | 19 ++++++- .../src/state/threadSnoozed.test.ts | 17 ++++++ packages/contracts/src/settings.ts | 10 ++++ 12 files changed, 151 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 670df0e3e12e..3a41d662737e 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -9,7 +9,8 @@ import { import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; import { Calendar } from "./ui/calendar"; -import { weekStartsOn } from "../timestampFormat"; +import { calendarWeekStartsOn } from "../timestampFormat"; +import { useClientSettings } from "../hooks/useSettings"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -63,6 +64,7 @@ function CustomSnoozeDialog() { const [amount, setAmount] = useState("2"); const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); const [error, setError] = useState(null); + const weekStartsOn = calendarWeekStartsOn(useClientSettings((s) => s.firstDayOfWeek)); const input: CustomSnoozeInput = mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit }; return ( diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index 38595f3a1654..fc01e5d37534 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -1,11 +1,11 @@ -import type { TimestampFormat } from "@t3tools/contracts/settings"; +import type { FirstDayOfWeek, TimestampFormat } from "@t3tools/contracts/settings"; import { resolveSnoozePresets as resolveSharedSnoozePresets, snoozeWakeLabel, type SnoozePreset, } from "@t3tools/client-runtime/state/thread-settled"; -import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat"; +import { formatShortTimestamp, parseTimestampDate, snoozeWeekStartsOn } from "../timestampFormat"; export { snoozeWakeLabel, type SnoozePreset }; @@ -16,8 +16,11 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { export function resolveSnoozePresets( now: Date, timestampFormat: TimestampFormat, + firstDayOfWeek: FirstDayOfWeek, ): ReadonlyArray { - return resolveSharedSnoozePresets(now).map((preset) => { + return resolveSharedSnoozePresets(now, { + weekStartsOn: snoozeWeekStartsOn(firstDayOfWeek), + }).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 9447ce0ba661..decebb30be3e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -759,6 +759,7 @@ export default function Sidebar() { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const firstDayOfWeek = useClientSettings((s) => s.firstDayOfWeek); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, @@ -1968,7 +1969,7 @@ export default function Sidebar() { const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, firstDayOfWeek); const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -2261,7 +2262,7 @@ export default function Sidebar() { const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; - const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat, firstDayOfWeek); const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( @@ -2929,6 +2930,7 @@ export default function Sidebar() { EMPTY_PROVIDER_ENTRIES } timestampFormat={timestampFormat} + firstDayOfWeek={firstDayOfWeek} onThreadClick={handleThreadClick} onThreadActivate={navigateToThread} onStartRename={startThreadRename} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 649f26ee1e7f..2ad5dc452920 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -19,6 +19,7 @@ import { type ResponseStreamingMode, type QuitConfirmationMode, SidebarProjectSortOrder, + FirstDayOfWeek, } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Equal from "effect/Equal"; @@ -120,6 +121,13 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const FIRST_DAY_OF_WEEK_LABELS: Record = { + locale: "System default", + sunday: "Sunday", + monday: "Monday", +}; +const isFirstDayOfWeek = Schema.is(FirstDayOfWeek); + const DIFF_LAYOUT_LABELS: Record = { stacked: "Stacked", split: "Split", @@ -416,6 +424,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.firstDayOfWeek !== DEFAULT_UNIFIED_SETTINGS.firstDayOfWeek + ? ["First day of the week"] + : []), ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode ? ["Thread notifications"] : []), @@ -582,6 +593,7 @@ export function useSettingsRestore(onRestored?: () => void) { chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth, mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + firstDayOfWeek: DEFAULT_UNIFIED_SETTINGS.firstDayOfWeek, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, @@ -1192,6 +1204,51 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + firstDayOfWeek: DEFAULT_UNIFIED_SETTINGS.firstDayOfWeek, + }) + } + /> + ) : null + } + control={ + + } + /> void; onSnooze: (preset: Pick) => void; timestampFormat: TimestampFormat; + firstDayOfWeek: FirstDayOfWeek; }) { - const { open, onOpenChange, onSnooze, timestampFormat } = props; - const presets = open ? resolveSnoozePresets(new Date(), timestampFormat) : []; + const { open, onOpenChange, onSnooze, timestampFormat, firstDayOfWeek } = props; + const presets = open ? resolveSnoozePresets(new Date(), timestampFormat, firstDayOfWeek) : []; return ( @@ -535,6 +536,7 @@ type SidebarThreadRowProps = { projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; + firstDayOfWeek: FirstDayOfWeek; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; @@ -1396,6 +1398,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onOpenChange={setSnoozeMenuOpen} onSnooze={handleSnoozePreset} timestampFormat={props.timestampFormat} + firstDayOfWeek={props.firstDayOfWeek} /> ) : null} {props.settlementSupported ? ( diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 08ed2b24e4cf..7af5cda81cc7 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -91,6 +91,7 @@ export function useThreadActionMenu(input: { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const firstDayOfWeek = useClientSettings((s) => s.firstDayOfWeek); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path }); @@ -127,7 +128,7 @@ export function useThreadActionMenu(input: { titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), }; const isRegeneratingTitle = thread.titleRegeneration != null; - const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const snoozePresets = resolveSnoozePresets(now, timestampFormat, firstDayOfWeek); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), branch: thread.branch ?? null, diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 8c904e54a95b..6529ccce1fc2 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -1,4 +1,4 @@ -import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { type FirstDayOfWeek, type TimestampFormat } from "@t3tools/contracts/settings"; function getTimestampFormatOptions( timestampFormat: TimestampFormat, @@ -69,7 +69,25 @@ function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefin } } -export const weekStartsOn = resolveWeekStartsOn(timestampLocale); +const localeWeekStartsOn = resolveWeekStartsOn(timestampLocale); + +/** + * Week start for calendars: the user's explicit choice, otherwise the same + * locale timestamps are shown in. + */ +export function calendarWeekStartsOn(firstDayOfWeek: FirstDayOfWeek): WeekdayIndex | undefined { + if (firstDayOfWeek === "sunday") return 0; + if (firstDayOfWeek === "monday") return 1; + return localeWeekStartsOn; +} + +/** + * Week start for the "Next week" snooze. It means the start of the work week, + * so the automatic setting stays on Monday even where calendars start on Sunday. + */ +export function snoozeWeekStartsOn(firstDayOfWeek: FirstDayOfWeek): WeekdayIndex { + return firstDayOfWeek === "sunday" ? 0 : 1; +} const timestampFormatterCache = new Map(); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c64e95ec5293..97eba5f03093 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -236,3 +236,7 @@ several selected threads together. Choose **Wake thread** to bring a thread back A snoozed thread keeps working and stays hidden until its wake time, even while its agent wakes itself for background work. It comes back early only when it needs an approval or answer from you, when a run fails, or when you send it a message. + +**Next week** wakes the thread at 9 AM on the first day of your work week, Monday by +default. On web and desktop, change it in **Settings → General → First day of the week**; +the same setting decides which day the **Custom…** calendar starts on. diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index cd156ccdc2e0..8b1be16a1026 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -200,7 +200,19 @@ function addSnoozeDays(base: Date, days: number): Date { return next; } -export function resolveSnoozePresets(now: Date): ReadonlyArray { +/** + * 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". "Next week" wakes on the morning of + * `weekStartsOn` (a `Date#getDay` index, Monday by default). Calendar presets + * that land on the same instant collapse: the day before the week starts, + * "Tomorrow" and "Next week" are the same morning, so only "Tomorrow" is + * offered. + */ +export function resolveSnoozePresets( + now: Date, + options: { readonly weekStartsOn?: number } = {}, +): 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[] = [ @@ -236,8 +248,9 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { snoozedUntil: tomorrow.toISOString(), }); - const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; - const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); + const weekStartsOn = options.weekStartsOn ?? 1; + const daysUntilWeekStart = (weekStartsOn - now.getDay() + 7) % 7 || 7; + const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilWeekStart), MORNING_HOUR); 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 3c9d6fded5dc..4ea31cdf1df3 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -282,6 +282,23 @@ describe("resolveSnoozePresets", () => { ]); }); + it("puts next week on the requested week start", () => { + // Saturday 2026-09-05: with Sunday weeks, "Tomorrow" already is next week. + expect( + resolveSnoozePresets(localDate(2026, 9, 5, 10), { weekStartsOn: 0 }).map( + (preset) => preset.id, + ), + ).not.toContain("next-week"); + // Friday 2026-09-04: next week starts Sunday 2026-09-06. + const nextWeek = new Date( + resolveSnoozePresets(localDate(2026, 9, 4, 10), { weekStartsOn: 0 }).find( + (preset) => preset.id === "next-week", + )!.snoozedUntil, + ); + expect(nextWeek.getDay()).toBe(0); + expect(nextWeek.getDate()).toBe(6); + }); + it("drops next week on Sundays, when it lands on the same Monday as tomorrow", () => { const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1)); expect(presets.map((preset) => preset.id)).toEqual([ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 67b850f2d7e7..ab43817173a0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -51,6 +51,12 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +// "locale" lays calendars out by the locale's week and keeps "Next week" +// snoozes on Monday; an explicit day drives both. +export const FirstDayOfWeek = Schema.Literals(["locale", "sunday", "monday"]); +export type FirstDayOfWeek = typeof FirstDayOfWeek.Type; +const DEFAULT_FIRST_DAY_OF_WEEK: FirstDayOfWeek = "locale"; + export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; @@ -466,6 +472,9 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + firstDayOfWeek: FirstDayOfWeek.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_FIRST_DAY_OF_WEEK)), + ), snapShotEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), snapShotIncludeAccessibility: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), @@ -1709,6 +1718,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), + firstDayOfWeek: Schema.optionalKey(FirstDayOfWeek), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), snapShotShortcut: Schema.optionalKey(SnapShotShortcut),