From 21a99cbac6efdfa4dcb9390745264bde48e85dd8 Mon Sep 17 00:00:00 2001 From: Vladislav Nikonorov Date: Fri, 6 Nov 2026 11:51:27 +0100 Subject: [PATCH] fix(client): keep coinciding snooze options visible --- .../settings/SnoozePresetsSettings.tsx | 2 +- .../client-runtime/src/state/threadSettled.ts | 34 ++++++++++--------- .../src/state/threadSnoozed.test.ts | 34 ++++++++++++++++--- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.tsx b/apps/web/src/components/settings/SnoozePresetsSettings.tsx index 1f88e5541dce..a792deecd47d 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.tsx +++ b/apps/web/src/components/settings/SnoozePresetsSettings.tsx @@ -243,7 +243,7 @@ function SnoozePresetList(props: { return { tone: "muted", text: "Duplicate of an option above" } as const; } if (seenWakes.has(resolved.snoozedUntil)) { - return { tone: "muted", text: "Same time as an option above" } as const; + return { tone: "muted", text: "Combined with an option above" } as const; } seenIds.add(resolved.id); seenWakes.add(resolved.snoozedUntil); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index c247f9d80489..e003d4a887fb 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -257,14 +257,14 @@ function snoozePresetDaysAhead( return (WEEKDAY_INDEX[setting.day] - now.getDay() + 7) % 7 || 7; } -/** Labels follow the actual day gap, so a Monday option on a Sunday reads "Tomorrow". */ +/** Keep named weekdays recognizable even when they fall tomorrow. */ function snoozePresetLabel(setting: SnoozePresetSetting, daysAhead: number): string { if (setting.kind === "after") return `In ${formatSnoozeDuration(setting.minutes)}`; if (daysAhead === 0) { const hour = Number(setting.time.slice(0, 2)); return hour < 12 ? "This morning" : hour < 17 ? "This afternoon" : "This evening"; } - if (daysAhead === 1) return "Tomorrow"; + if (daysAhead === 1) return capitalize(setting.day); if (setting.day === "monday") return "Next week"; return daysAhead === 7 ? `Next ${capitalize(setting.day)}` : capitalize(setting.day); } @@ -311,21 +311,23 @@ export function resolveSnoozePresets( now: Date, options: SnoozePresetOptions = {}, ): ReadonlyArray { - // By minute, so a duration (which keeps the clock's seconds) and a set time in the same - // minute tie, and the stable sort keeps whichever comes first in settings. + // Durations keep the clock's seconds; ties within a minute keep the first setting's wake. const wakeMinute = (preset: SnoozePreset) => Math.floor(Date.parse(preset.snoozedUntil) / 60_000); - const seen = new Set(); - return ( - (options.presets ?? DEFAULT_SNOOZE_PRESETS) - .flatMap((setting) => resolveSnoozePreset(setting, now, options.formatTime) ?? []) - // flatMap returned a new array; Hermes has no `toSorted`. - .sort((a, b) => wakeMinute(a) - wakeMinute(b)) - .filter((preset) => { - if (seen.has(wakeMinute(preset))) return false; - seen.add(wakeMinute(preset)); - return true; - }) - ); + const presets = (options.presets ?? DEFAULT_SNOOZE_PRESETS) + .flatMap((setting) => resolveSnoozePreset(setting, now, options.formatTime) ?? []) + // flatMap returned a new array; Hermes has no `toSorted`. + .sort((a, b) => wakeMinute(a) - wakeMinute(b)); + const groups = new Map(); + for (const preset of presets) { + const minute = wakeMinute(preset); + const group = groups.get(minute); + if (group) group.push(preset); + else groups.set(minute, [preset]); + } + return [...groups.values()].map((group) => ({ + ...group[0]!, + label: [...new Set(group.map((preset) => preset.label))].join(" · "), + })); } /** diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 5da3be096509..86b108f63387 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -420,7 +420,7 @@ describe("resolveSnoozePresets", () => { expect(new Date(presets[0]!.snoozedUntil)).toEqual(localDate(2026, 4, 20, 9)); }); - it("names the real day gap: a Monday option on Sunday is Tomorrow, a weekday a week out is Next", () => { + it("keeps named weekdays visible and names a weekday a week out as Next", () => { const theo: ReadonlyArray = [ { kind: "at", day: "tomorrow", time: "21:00" }, { kind: "at", day: "monday", time: "18:00" }, @@ -428,7 +428,7 @@ describe("resolveSnoozePresets", () => { ]; // Sunday 14:00. expect(menu(localDate(2026, 8, 30, 14), theo)).toEqual([ - "Tomorrow 18:00", + "Monday 18:00", "Tomorrow 21:00", "Friday 17:00", ]); @@ -446,7 +446,33 @@ describe("resolveSnoozePresets", () => { "In 1 hour 8:01", "In 3 hours 10:01", "This evening 18:00", - "Tomorrow 9:00", + "Tomorrow · Monday 9:00", + ]); + }); + + it("keeps every configured option visible when Tomorrow and Saturday coincide", () => { + const presets: ReadonlyArray = [ + { kind: "after", minutes: 60 }, + { kind: "after", minutes: 180 }, + { kind: "at", day: "today", time: "18:00" }, + { kind: "at", day: "tomorrow", time: "09:00" }, + { kind: "at", day: "saturday", time: "09:00" }, + { kind: "at", day: "monday", time: "09:00" }, + ]; + const now = localDate(2026, 11, 6, 12, 2); + expect(menu(now, presets)).toEqual([ + "In 1 hour 13:02", + "In 3 hours 15:02", + "This evening 18:00", + "Tomorrow · Saturday 9:00", + "Next week Mon 9:00", + ]); + const merged = resolveSnoozePresets(now, { presets })[3]!; + expect(merged.id).toBe("at:tomorrow:09:00"); + expect(merged.snoozedUntil).toBe(localDate(2026, 11, 7, 9).toISOString()); + expect(menu(now, [...presets].reverse())[3]).toBe("Saturday · Tomorrow 9:00"); + expect(menu(now, [presets[3]!, presets[3]!, presets[4]!])).toEqual([ + "Tomorrow · Saturday 9:00", ]); }); @@ -458,7 +484,7 @@ describe("resolveSnoozePresets", () => { { kind: "after", minutes: 180 }, { kind: "at", day: "today", time: "18:00" }, ]), - ).toEqual(["In 3 hours 18:00"]); + ).toEqual(["In 3 hours · This evening 18:00"]); }); });