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
1010describe("resolveSnoozePresets", () => {
1111 it("offers hour, evening, tomorrow, next week in the morning", () => {
1212 // 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");
1414 expect(presets.map((preset) => preset.id)).toEqual([
1515 "hour",
1616 "evening",
......@@ -30,7 +30,7 @@ describe("resolveSnoozePresets", () => {
3030 });
3131
3232 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");
3434 for (const preset of presets) {
3535 // Day words live in the label column; the time column is time-only
3636 // (plus a weekday for next week, which names a different day).
......@@ -43,37 +43,51 @@ describe("resolveSnoozePresets", () => {
4343 });
4444
4545 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([
47 "hour",
48 "tomorrow",
49 "next-week",
50 ]);
51 expect(resolveSnoozePresets(localDate(2026, 4, 8, 21)).map((preset) => preset.id)).toEqual([
52 "hour",
53 "tomorrow",
54 "next-week",
55 ]);
46 expect(
47 resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale").map((preset) => preset.id),
48 ).toEqual(["hour", "tomorrow", "next-week"]);
49 expect(
50 resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale").map((preset) => preset.id),
51 ).toEqual(["hour", "tomorrow", "next-week"]);
5652 });
5753
5854 it("puts next week a full week out when today is Monday", () => {
5955 // Monday 2026-04-06.
60 const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10));
56 const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale");
6157 const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil);
6258 expect(nextWeek.getDay()).toBe(1);
6359 expect(nextWeek.getDate()).toBe(13);
6460 });
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 });
6568});
6669
6770describe("snoozeWakeDescription", () => {
6871 const now = localDate(2026, 4, 8, 10);
6972
7073 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(
7278 "tomorrow",
7379 );
74 expect(snoozeWakeDescription(localDate(2026, 4, 9, 9).toISOString(), now)).toContain(
75 "tomorrow",
80 expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now, "locale")).toMatch(
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",
7691 );
77 expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now)).toMatch(/Mon/);
7892 });
7993});
apps/web/src/components/Sidebar.snooze.ts+36-8
......@@ -1,22 +1,50 @@
1import { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled";
2import { parseTimestampDate } from "../timestampFormat";
3
4export {
5 resolveSnoozePresets,
1import type { TimestampFormat } from "@t3tools/contracts/settings";
2import {
3 resolveSnoozePresets as resolveSharedSnoozePresets,
4 snoozeWakeLabel,
65 type SnoozePreset,
76} from "@t3tools/client-runtime/state/thread-settled";
8export { snoozeWakeLabel };
7
8import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat";
9
10export { snoozeWakeLabel, type SnoozePreset };
911
1012const 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
1236/**
1337 * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00",
1438 * "17:30" (today).
1539 */
16export function snoozeWakeDescription(snoozedUntil: string, now: Date): string {
40export function snoozeWakeDescription(
41 snoozedUntil: string,
42 now: Date,
43 timestampFormat: TimestampFormat,
44): string {
1745 const wake = parseTimestampDate(snoozedUntil);
1846 if (wake === null) return "";
19 const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
47 const time = timeOfDayLabel(wake, timestampFormat);
2048 const startOfToday = new Date(now);
2149 startOfToday.setHours(0, 0, 0, 0);
2250 const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS);
apps/web/src/components/SidebarV2.tsx+17-6
......@@ -13,6 +13,7 @@ import {
1313 scopedThreadKey,
1414} from "@t3tools/client-runtime/environment";
1515import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
16import type { TimestampFormat } from "@t3tools/contracts/settings";
1617import {
1718 AlarmClockIcon,
1819 AlarmClockOffIcon,
......@@ -341,11 +342,15 @@ function SnoozePopoverButton(props: {
341342 open: boolean;
342343 onOpenChange: (open: boolean) => void;
343344 onSnooze: (preset: SnoozePreset) => void;
345 timestampFormat: TimestampFormat;
344346}) {
345 const { open, onOpenChange, onSnooze } = props;
347 const { open, onOpenChange, onSnooze, timestampFormat } = props;
346348 // Presets resolve at open time so "In 1 hour" is relative to the click,
347349 // 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 );
349354 return (
350355 <Popover open={open} onOpenChange={onOpenChange}>
351356 <PopoverTrigger
......@@ -412,6 +417,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
412417 projectCwd: string | null;
413418 projectTitle: string | null;
414419 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
420 timestampFormat: TimestampFormat;
415421 onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
416422 onThreadActivate: (threadRef: ScopedThreadRef) => void;
417423 onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
......@@ -1020,6 +1026,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
10201026 open={snoozeMenuOpen}
10211027 onOpenChange={setSnoozeMenuOpen}
10221028 onSnooze={handleSnoozePreset}
1029 timestampFormat={props.timestampFormat}
10231030 />
10241031 ) : null}
10251032 {props.settlementSupported ? (
......@@ -1207,6 +1214,7 @@ export default function SidebarV2() {
12071214 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
12081215 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
12091216 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
1217 const timestampFormat = useClientSettings((s) => s.timestampFormat);
12101218 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
12111219 const {
12121220 settleThread,
......@@ -2156,7 +2164,7 @@ export default function SidebarV2() {
21562164 toastManager.add(
21572165 stackedThreadToast({
21582166 type: "success",
2159 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`,
2167 title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`,
21602168 timeout: 5_000,
21612169 actionProps: {
21622170 children: "Undo",
......@@ -2174,7 +2182,7 @@ export default function SidebarV2() {
21742182 }
21752183 })();
21762184 },
2177 [attemptUnsnooze, planForwardNavigation, snoozeThread],
2185 [attemptUnsnooze, planForwardNavigation, snoozeThread, timestampFormat],
21782186 );
21792187
21802188 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
......@@ -2215,7 +2223,7 @@ export default function SidebarV2() {
22152223 supportedCount: titleRegenerationThreads.length,
22162224 actionableCount: regeneratableTitleThreads.length,
22172225 });
2218 const snoozePresets = resolveSnoozePresets(new Date());
2226 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
22192227 const clicked = await settlePromise(() =>
22202228 api.contextMenu.show(
22212229 [
......@@ -2352,6 +2360,7 @@ export default function SidebarV2() {
23522360 removeFromSelection,
23532361 serverConfigs,
23542362 updateThreadMetadata,
2363 timestampFormat,
23552364 ],
23562365 );
23572366
......@@ -2391,7 +2400,7 @@ export default function SidebarV2() {
23912400 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
23922401 const isPinned = thread.pinnedAt != null;
23932402 // Presets resolve at menu-open time (same as the popover).
2394 const snoozePresets = resolveSnoozePresets(new Date());
2403 const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
23952404 const clicked = await settlePromise(() =>
23962405 api.contextMenu.show(
23972406 [
......@@ -2590,6 +2599,7 @@ export default function SidebarV2() {
25902599 serverConfigs,
25912600 startThreadRename,
25922601 updateThreadMetadata,
2602 timestampFormat,
25932603 ],
25942604 );
25952605
......@@ -2998,6 +3008,7 @@ export default function SidebarV2() {
29983008 ) ?? null
29993009 }
30003010 providerEntryByInstanceId={providerEntryByInstanceId}
3011 timestampFormat={timestampFormat}
30013012 onThreadClick={handleThreadClick}
30023013 onThreadActivate={navigateToThread}
30033014 onStartRename={startThreadRename}