authorgravatar for 6117705+huxcrux@users.noreply.github.comHugo Blom <6117705+huxcrux@users.noreply.github.com> 2026-08-06 11:40:58+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 11:40:58+02:00
loga483337a02d4ac641db0219517816c300a33be6b
tree3fff1dbfa1ddf19c9e7b6f3834154761a48a5cf5
parent1ffba7093a83c99ef41dd486b10df75f2b9974e1
signature Signed by PGP key B5690EEEBB952194

fix: respect time format for sidebar snooze (#4438)


3 files changed, 84 insertions(+), 31 deletions(-)

apps/web/src/components/Sidebar.snooze.test.ts+31-17
...@@ -10,7 +10,7 @@ function localDate(year: number, month: number, day: number, hour: number, minut...@@ -10,7 +10,7 @@ function localDate(year: number, month: number, day: number, hour: number, minut
10describe("resolveSnoozePresets", () => {10describe("resolveSnoozePresets", () => {
11 it("offers hour, evening, tomorrow, next week in the morning", () => {11 it("offers hour, evening, tomorrow, next week in the morning", () => {
12 // Wednesday 2026-04-08 10:00 local.12 // Wednesday 2026-04-08 10:00 local.
13 const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10));13 const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale");
14 expect(presets.map((preset) => preset.id)).toEqual([14 expect(presets.map((preset) => preset.id)).toEqual([
15 "hour",15 "hour",
16 "evening",16 "evening",
...@@ -30,7 +30,7 @@ describe("resolveSnoozePresets", () => {...@@ -30,7 +30,7 @@ describe("resolveSnoozePresets", () => {
30 });30 });
3131
32 it("whenLabel complements the label instead of repeating it", () => {32 it("whenLabel complements the label instead of repeating it", () => {
33 const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10));33 const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale");
34 for (const preset of presets) {34 for (const preset of presets) {
35 // Day words live in the label column; the time column is time-only35 // Day words live in the label column; the time column is time-only
36 // (plus a weekday for next week, which names a different day).36 // (plus a weekday for next week, which names a different day).
...@@ -43,37 +43,51 @@ describe("resolveSnoozePresets", () => {...@@ -43,37 +43,51 @@ describe("resolveSnoozePresets", () => {
43 });43 });
4444
45 it("drops the evening preset once evening is near or past", () => {45 it("drops the evening preset once evening is near or past", () => {
46 expect(resolveSnoozePresets(localDate(2026, 4, 8, 17, 30)).map((preset) => preset.id)).toEqual([46 expect(
47 "hour",47 resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale").map((preset) => preset.id),
48 "tomorrow",48 ).toEqual(["hour", "tomorrow", "next-week"]);
49 "next-week",49 expect(
50 ]);50 resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale").map((preset) => preset.id),
51 expect(resolveSnoozePresets(localDate(2026, 4, 8, 21)).map((preset) => preset.id)).toEqual([51 ).toEqual(["hour", "tomorrow", "next-week"]);
52 "hour",
53 "tomorrow",
54 "next-week",
55 ]);
56 });52 });
5753
58 it("puts next week a full week out when today is Monday", () => {54 it("puts next week a full week out when today is Monday", () => {
59 // Monday 2026-04-06.55 // Monday 2026-04-06.
60 const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10));56 const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale");
61 const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil);57 const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil);
62 expect(nextWeek.getDay()).toBe(1);58 expect(nextWeek.getDay()).toBe(1);
63 expect(nextWeek.getDate()).toBe(13);59 expect(nextWeek.getDate()).toBe(13);
64 });60 });
61 it("formats preset times with the selected clock preference", () => {
62 const twelveHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "12-hour");
63 const twentyFourHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour");
64
65 expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i);
66 expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00");
67 });
65});68});
6669
67describe("snoozeWakeDescription", () => {70describe("snoozeWakeDescription", () => {
68 const now = localDate(2026, 4, 8, 10);71 const now = localDate(2026, 4, 8, 10);
6972
70 it("uses bare time today, 'tomorrow' next day, weekday within the week", () => {73 it("uses bare time today, 'tomorrow' next day, weekday within the week", () => {
71 expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now)).not.toContain(74 expect(
75 snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now, "locale"),
76 ).not.toContain("tomorrow");
77 expect(snoozeWakeDescription(localDate(2026, 4, 9, 9).toISOString(), now, "locale")).toContain(
72 "tomorrow",78 "tomorrow",
73 );79 );
74 expect(snoozeWakeDescription(localDate(2026, 4, 9, 9).toISOString(), now)).toContain(80 expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now, "locale")).toMatch(
75 "tomorrow",81 /Mon/,
82 );
83 });
84
85 it("formats wake descriptions with the selected clock preference", () => {
86 expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now, "12-hour")).toMatch(
87 /PM/i,
88 );
89 expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now, "24-hour")).toBe(
90 "18:00",
76 );91 );
77 expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now)).toMatch(/Mon/);
78 });92 });
79});93});
apps/web/src/components/Sidebar.snooze.ts+36-8
...@@ -1,22 +1,50 @@...@@ -1,22 +1,50 @@
1import { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled";1import type { TimestampFormat } from "@t3tools/contracts/settings";
2import { parseTimestampDate } from "../timestampFormat";2import {
33 resolveSnoozePresets as resolveSharedSnoozePresets,
4export {4 snoozeWakeLabel,
5 resolveSnoozePresets,
6 type SnoozePreset,5 type SnoozePreset,
7} from "@t3tools/client-runtime/state/thread-settled";6} from "@t3tools/client-runtime/state/thread-settled";
8export { snoozeWakeLabel };7
8import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat";
9
10export { snoozeWakeLabel, type SnoozePreset };
911
10const DAY_MS = 24 * 60 * 60 * 1_000;12const DAY_MS = 24 * 60 * 60 * 1_000;
1113
14function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string {
15 return formatShortTimestamp(date.toISOString(), timestampFormat);
16}
17
18export function resolveSnoozePresets(
19 now: Date,
20 timestampFormat: TimestampFormat,
21): ReadonlyArray<SnoozePreset> {
22 return resolveSharedSnoozePresets(now).map((preset) => {
23 const wake = parseTimestampDate(preset.snoozedUntil);
24 if (wake === null) return preset;
25 const time = timeOfDayLabel(wake, timestampFormat);
26 return {
27 ...preset,
28 whenLabel:
29 preset.id === "next-week"
30 ? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}`
31 : time,
32 };
33 });
34}
35
12/**36/**
13 * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00",37 * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00",
14 * "17:30" (today).38 * "17:30" (today).
15 */39 */
16export function snoozeWakeDescription(snoozedUntil: string, now: Date): string {40export function snoozeWakeDescription(
41 snoozedUntil: string,
42 now: Date,
43 timestampFormat: TimestampFormat,
44): string {
17 const wake = parseTimestampDate(snoozedUntil);45 const wake = parseTimestampDate(snoozedUntil);
18 if (wake === null) return "";46 if (wake === null) return "";
19 const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });47 const time = timeOfDayLabel(wake, timestampFormat);
20 const startOfToday = new Date(now);48 const startOfToday = new Date(now);
21 startOfToday.setHours(0, 0, 0, 0);49 startOfToday.setHours(0, 0, 0, 0);
22 const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS);50 const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS);
apps/web/src/components/SidebarV2.tsx+17-6
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 scopedThreadKey,13 scopedThreadKey,
14} from "@t3tools/client-runtime/environment";14} from "@t3tools/client-runtime/environment";
15import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";15import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
16import type { TimestampFormat } from "@t3tools/contracts/settings";
16import {17import {
17 AlarmClockIcon,18 AlarmClockIcon,
18 AlarmClockOffIcon,19 AlarmClockOffIcon,
...@@ -341,11 +342,15 @@ function SnoozePopoverButton(props: {...@@ -341,11 +342,15 @@ function SnoozePopoverButton(props: {
341 open: boolean;342 open: boolean;
342 onOpenChange: (open: boolean) => void;343 onOpenChange: (open: boolean) => void;
343 onSnooze: (preset: SnoozePreset) => void;344 onSnooze: (preset: SnoozePreset) => void;
345 timestampFormat: TimestampFormat;
344}) {346}) {
345 const { open, onOpenChange, onSnooze } = props;347 const { open, onOpenChange, onSnooze, timestampFormat } = props;
346 // Presets resolve at open time so "In 1 hour" is relative to the click,348 // Presets resolve at open time so "In 1 hour" is relative to the click,
347 // not to when the row mounted.349 // not to when the row mounted.
348 const presets = useMemo(() => (open ? resolveSnoozePresets(new Date()) : []), [open]);350 const presets = useMemo(
351 () => (open ? resolveSnoozePresets(new Date(), timestampFormat) : []),
352 [open, timestampFormat],
353 );
349 return (354 return (
350 <Popover open={open} onOpenChange={onOpenChange}>355 <Popover open={open} onOpenChange={onOpenChange}>
351 <PopoverTrigger356 <PopoverTrigger
...@@ -412,6 +417,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -412,6 +417,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
412 projectCwd: string | null;417 projectCwd: string | null;
413 projectTitle: string | null;418 projectTitle: string | null;
414 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;419 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
420 timestampFormat: TimestampFormat;
415 onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;421 onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
416 onThreadActivate: (threadRef: ScopedThreadRef) => void;422 onThreadActivate: (threadRef: ScopedThreadRef) => void;
417 onStartRename: (threadRef: ScopedThreadRef, title: string) => void;423 onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
...@@ -1020,6 +1026,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -1020,6 +1026,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
1020 open={snoozeMenuOpen}1026 open={snoozeMenuOpen}
1021 onOpenChange={setSnoozeMenuOpen}1027 onOpenChange={setSnoozeMenuOpen}
1022 onSnooze={handleSnoozePreset}1028 onSnooze={handleSnoozePreset}
1029 timestampFormat={props.timestampFormat}
1023 />1030 />
1024 ) : null}1031 ) : null}
1025 {props.settlementSupported ? (1032 {props.settlementSupported ? (
...@@ -1207,6 +1214,7 @@ export default function SidebarV2() {...@@ -1207,6 +1214,7 @@ export default function SidebarV2() {
1207 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);1214 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
1208 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);1215 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1209 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);1216 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
1217 const timestampFormat = useClientSettings((s) => s.timestampFormat);
1210 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);1218 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1211 const {1219 const {
1212 settleThread,1220 settleThread,
...@@ -2156,7 +2164,7 @@ export default function SidebarV2() {...@@ -2156,7 +2164,7 @@ export default function SidebarV2() {
2156 toastManager.add(2164 toastManager.add(
2157 stackedThreadToast({2165 stackedThreadToast({
2158 type: "success",2166 type: "success",
2159 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`,2167 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,
2160 timeout: 5_000,2168 timeout: 5_000,
2161 actionProps: {2169 actionProps: {
2162 children: "Undo",2170 children: "Undo",
...@@ -2174,7 +2182,7 @@ export default function SidebarV2() {...@@ -2174,7 +2182,7 @@ export default function SidebarV2() {
2174 }2182 }
2175 })();2183 })();
2176 },2184 },
2177 [attemptUnsnooze, planForwardNavigation, snoozeThread],2185 [attemptUnsnooze, planForwardNavigation, snoozeThread, timestampFormat],
2178 );2186 );
21792187
2180 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);2188 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
...@@ -2215,7 +2223,7 @@ export default function SidebarV2() {...@@ -2215,7 +2223,7 @@ export default function SidebarV2() {
2215 supportedCount: titleRegenerationThreads.length,2223 supportedCount: titleRegenerationThreads.length,
2216 actionableCount: regeneratableTitleThreads.length,2224 actionableCount: regeneratableTitleThreads.length,
2217 });2225 });
2218 const snoozePresets = resolveSnoozePresets(new Date());2226 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
2219 const clicked = await settlePromise(() =>2227 const clicked = await settlePromise(() =>
2220 api.contextMenu.show(2228 api.contextMenu.show(
2221 [2229 [
...@@ -2352,6 +2360,7 @@ export default function SidebarV2() {...@@ -2352,6 +2360,7 @@ export default function SidebarV2() {
2352 removeFromSelection,2360 removeFromSelection,
2353 serverConfigs,2361 serverConfigs,
2354 updateThreadMetadata,2362 updateThreadMetadata,
2363 timestampFormat,
2355 ],2364 ],
2356 );2365 );
23572366
...@@ -2391,7 +2400,7 @@ export default function SidebarV2() {...@@ -2391,7 +2400,7 @@ export default function SidebarV2() {
2391 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);2400 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
2392 const isPinned = thread.pinnedAt != null;2401 const isPinned = thread.pinnedAt != null;
2393 // Presets resolve at menu-open time (same as the popover).2402 // Presets resolve at menu-open time (same as the popover).
2394 const snoozePresets = resolveSnoozePresets(new Date());2403 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
2395 const clicked = await settlePromise(() =>2404 const clicked = await settlePromise(() =>
2396 api.contextMenu.show(2405 api.contextMenu.show(
2397 [2406 [
...@@ -2590,6 +2599,7 @@ export default function SidebarV2() {...@@ -2590,6 +2599,7 @@ export default function SidebarV2() {
2590 serverConfigs,2599 serverConfigs,
2591 startThreadRename,2600 startThreadRename,
2592 updateThreadMetadata,2601 updateThreadMetadata,
2602 timestampFormat,
2593 ],2603 ],
2594 );2604 );
25952605
...@@ -2998,6 +3008,7 @@ export default function SidebarV2() {...@@ -2998,6 +3008,7 @@ export default function SidebarV2() {
2998 ) ?? null3008 ) ?? null
2999 }3009 }
3000 providerEntryByInstanceId={providerEntryByInstanceId}3010 providerEntryByInstanceId={providerEntryByInstanceId}
3011 timestampFormat={timestampFormat}
3001 onThreadClick={handleThreadClick}3012 onThreadClick={handleThreadClick}
3002 onThreadActivate={navigateToThread}3013 onThreadActivate={navigateToThread}
3003 onStartRename={startThreadRename}3014 onStartRename={startThreadRename}