| author | |
| committer | |
| log | a483337a02d4ac641db0219517816c300a33be6b |
| tree | 3fff1dbfa1ddf19c9e7b6f3834154761a48a5cf5 |
| parent | 1ffba7093a83c99ef41dd486b10df75f2b9974e1 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 10 | describe("resolveSnoozePresets", () => { |
| 11 | 11 | it("offers hour, evening, tomorrow, next week in the morning", () => { |
| 12 | 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 | 14 | expect(presets.map((preset) => preset.id)).toEqual([ |
| 15 | 15 | "hour", |
| 16 | 16 | "evening", |
| ... | ... | @@ -30,7 +30,7 @@ describe("resolveSnoozePresets", () => { |
| 30 | 30 | }); |
| 31 | 31 | |
| 32 | 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 | 34 | for (const preset of presets) { |
| 35 | 35 | // Day words live in the label column; the time column is time-only |
| 36 | 36 | // (plus a weekday for next week, which names a different day). |
| ... | ... | @@ -43,37 +43,51 @@ describe("resolveSnoozePresets", () => { |
| 43 | 43 | }); |
| 44 | 44 | |
| 45 | 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([ | |
| 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"]); | |
| 56 | 52 | }); |
| 57 | 53 | |
| 58 | 54 | it("puts next week a full week out when today is Monday", () => { |
| 59 | 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 | 57 | const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); |
| 62 | 58 | expect(nextWeek.getDay()).toBe(1); |
| 63 | 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 | }); |
| 66 | 69 | |
| 67 | 70 | describe("snoozeWakeDescription", () => { |
| 68 | 71 | const now = localDate(2026, 4, 8, 10); |
| 69 | 72 | |
| 70 | 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 | 78 | "tomorrow", |
| 73 | 79 | ); |
| 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", | |
| 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 | import { snoozeWakeLabel } from "@t3tools/client-runtime/state/thread-settled"; | |
| 2 | import { parseTimestampDate } from "../timestampFormat"; | |
| 3 | ||
| 4 | export { | |
| 5 | resolveSnoozePresets, | |
| 1 | import type { TimestampFormat } from "@t3tools/contracts/settings"; | |
| 2 | import { | |
| 3 | resolveSnoozePresets as resolveSharedSnoozePresets, | |
| 4 | snoozeWakeLabel, | |
| 6 | 5 | type SnoozePreset, |
| 7 | 6 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 8 | export { snoozeWakeLabel }; | |
| 7 | ||
| 8 | import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat"; | |
| 9 | ||
| 10 | export { snoozeWakeLabel, type SnoozePreset }; | |
| 9 | 11 | |
| 10 | 12 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 11 | 13 | |
| 14 | function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { | |
| 15 | return formatShortTimestamp(date.toISOString(), timestampFormat); | |
| 16 | } | |
| 17 | ||
| 18 | export 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 | 37 | * Human wake time for menus and toasts: "tomorrow 9:00", "Mon 9:00", |
| 14 | 38 | * "17:30" (today). |
| 15 | 39 | */ |
| 16 | export function snoozeWakeDescription(snoozedUntil: string, now: Date): string { | |
| 40 | export function snoozeWakeDescription( | |
| 41 | snoozedUntil: string, | |
| 42 | now: Date, | |
| 43 | timestampFormat: TimestampFormat, | |
| 44 | ): string { | |
| 17 | 45 | const wake = parseTimestampDate(snoozedUntil); |
| 18 | 46 | if (wake === null) return ""; |
| 19 | const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); | |
| 47 | const time = timeOfDayLabel(wake, timestampFormat); | |
| 20 | 48 | const startOfToday = new Date(now); |
| 21 | 49 | startOfToday.setHours(0, 0, 0, 0); |
| 22 | 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 | 13 | scopedThreadKey, |
| 14 | 14 | } from "@t3tools/client-runtime/environment"; |
| 15 | 15 | import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; |
| 16 | import type { TimestampFormat } from "@t3tools/contracts/settings"; | |
| 16 | 17 | import { |
| 17 | 18 | AlarmClockIcon, |
| 18 | 19 | AlarmClockOffIcon, |
| ... | ... | @@ -341,11 +342,15 @@ function SnoozePopoverButton(props: { |
| 341 | 342 | open: boolean; |
| 342 | 343 | onOpenChange: (open: boolean) => void; |
| 343 | 344 | onSnooze: (preset: SnoozePreset) => void; |
| 345 | timestampFormat: TimestampFormat; | |
| 344 | 346 | }) { |
| 345 | const { open, onOpenChange, onSnooze } = props; | |
| 347 | const { open, onOpenChange, onSnooze, timestampFormat } = props; | |
| 346 | 348 | // Presets resolve at open time so "In 1 hour" is relative to the click, |
| 347 | 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 | 354 | return ( |
| 350 | 355 | <Popover open={open} onOpenChange={onOpenChange}> |
| 351 | 356 | <PopoverTrigger |
| ... | ... | @@ -412,6 +417,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 412 | 417 | projectCwd: string | null; |
| 413 | 418 | projectTitle: string | null; |
| 414 | 419 | providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>; |
| 420 | timestampFormat: TimestampFormat; | |
| 415 | 421 | onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; |
| 416 | 422 | onThreadActivate: (threadRef: ScopedThreadRef) => void; |
| 417 | 423 | onStartRename: (threadRef: ScopedThreadRef, title: string) => void; |
| ... | ... | @@ -1020,6 +1026,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { |
| 1020 | 1026 | open={snoozeMenuOpen} |
| 1021 | 1027 | onOpenChange={setSnoozeMenuOpen} |
| 1022 | 1028 | onSnooze={handleSnoozePreset} |
| 1029 | timestampFormat={props.timestampFormat} | |
| 1023 | 1030 | /> |
| 1024 | 1031 | ) : null} |
| 1025 | 1032 | {props.settlementSupported ? ( |
| ... | ... | @@ -1207,6 +1214,7 @@ export default function SidebarV2() { |
| 1207 | 1214 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); |
| 1208 | 1215 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1209 | 1216 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 1217 | const timestampFormat = useClientSettings((s) => s.timestampFormat); | |
| 1210 | 1218 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 1211 | 1219 | const { |
| 1212 | 1220 | settleThread, |
| ... | ... | @@ -2156,7 +2164,7 @@ export default function SidebarV2() { |
| 2156 | 2164 | toastManager.add( |
| 2157 | 2165 | stackedThreadToast({ |
| 2158 | 2166 | type: "success", |
| 2159 | title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date())}`, | |
| 2167 | title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, | |
| 2160 | 2168 | timeout: 5_000, |
| 2161 | 2169 | actionProps: { |
| 2162 | 2170 | children: "Undo", |
| ... | ... | @@ -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 | ); |
| 2179 | 2187 | |
| 2180 | 2188 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); |
| ... | ... | @@ -2215,7 +2223,7 @@ export default function SidebarV2() { |
| 2215 | 2223 | supportedCount: titleRegenerationThreads.length, |
| 2216 | 2224 | actionableCount: regeneratableTitleThreads.length, |
| 2217 | 2225 | }); |
| 2218 | const snoozePresets = resolveSnoozePresets(new Date()); | |
| 2226 | const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); | |
| 2219 | 2227 | const clicked = await settlePromise(() => |
| 2220 | 2228 | api.contextMenu.show( |
| 2221 | 2229 | [ |
| ... | ... | @@ -2352,6 +2360,7 @@ export default function SidebarV2() { |
| 2352 | 2360 | removeFromSelection, |
| 2353 | 2361 | serverConfigs, |
| 2354 | 2362 | updateThreadMetadata, |
| 2363 | timestampFormat, | |
| 2355 | 2364 | ], |
| 2356 | 2365 | ); |
| 2357 | 2366 | |
| ... | ... | @@ -2391,7 +2400,7 @@ export default function SidebarV2() { |
| 2391 | 2400 | const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); |
| 2392 | 2401 | const isPinned = thread.pinnedAt != null; |
| 2393 | 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 | 2404 | const clicked = await settlePromise(() => |
| 2396 | 2405 | api.contextMenu.show( |
| 2397 | 2406 | [ |
| ... | ... | @@ -2590,6 +2599,7 @@ export default function SidebarV2() { |
| 2590 | 2599 | serverConfigs, |
| 2591 | 2600 | startThreadRename, |
| 2592 | 2601 | updateThreadMetadata, |
| 2602 | timestampFormat, | |
| 2593 | 2603 | ], |
| 2594 | 2604 | ); |
| 2595 | 2605 | |
| ... | ... | @@ -2998,6 +3008,7 @@ export default function SidebarV2() { |
| 2998 | 3008 | ) ?? null |
| 2999 | 3009 | } |
| 3000 | 3010 | providerEntryByInstanceId={providerEntryByInstanceId} |
| 3011 | timestampFormat={timestampFormat} | |
| 3001 | 3012 | onThreadClick={handleThreadClick} |
| 3002 | 3013 | onThreadActivate={navigateToThread} |
| 3003 | 3014 | onStartRename={startThreadRename} |