From 684302f494006d41e07ed377e5ee2a8e6380b510 Mon Sep 17 00:00:00 2001 From: Marc Neuhaus Date: Wed, 30 Sep 2026 18:21:10 +0200 Subject: [PATCH] feat(clients): add an "In 1 month" snooze preset The snooze menu stopped at "Next week", so parking a thread for longer meant typing a custom date. "In 1 month" wakes the thread on the same day one month later at 9:00 (Sep 29 wakes Oct 29). Days the next month lacks clamp to its last day: Jan 31 wakes on Feb 28, or Feb 29 in leap years. Being at least 28 days out, it never collides with "Tomorrow" or "Next week". The preset lives in the shared client runtime, so the web hover menu, thread context menu, bulk snooze and the mobile swipe menu all offer it. --- .../web/src/components/Sidebar.snooze.test.ts | 40 +++++++++++++++++-- apps/web/src/components/Sidebar.snooze.ts | 4 +- .../client-runtime/src/state/threadSettled.ts | 31 +++++++++++++- .../src/state/threadSnoozed.test.ts | 3 ++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 16e17e4217eb..06eec8009fdf 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -8,7 +8,7 @@ function localDate(year: number, month: number, day: number, hour: number, minut } describe("resolveSnoozePresets", () => { - it("offers one hour, three hours, evening, tomorrow, and next week in the morning", () => { + it("offers one hour, three hours, evening, tomorrow, next week, and in a month in the morning", () => { // Wednesday 2026-04-08 10:00 local. const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale"); expect(presets.map((preset) => preset.id)).toEqual([ @@ -17,6 +17,7 @@ describe("resolveSnoozePresets", () => { "evening", "tomorrow", "next-week", + "month", ]); const threeHours = presets.find((preset) => preset.id === "three-hours"); expect(new Date(threeHours!.snoozedUntil).getHours()).toBe(13); @@ -30,6 +31,11 @@ describe("resolveSnoozePresets", () => { const nextWeekDate = new Date(nextWeek!.snoozedUntil); expect(nextWeekDate.getDay()).toBe(1); expect(nextWeekDate.getDate()).toBe(13); + const inAMonth = presets.find((preset) => preset.id === "month"); + const inAMonthDate = new Date(inAMonth!.snoozedUntil); + expect([inAMonthDate.getMonth(), inAMonthDate.getDate(), inAMonthDate.getHours()]).toEqual([ + 4, 8, 9, + ]); }); it("whenLabel complements the label instead of repeating it", () => { @@ -43,15 +49,18 @@ describe("resolveSnoozePresets", () => { expect(tomorrow!.whenLabel).toMatch(/9/); const nextWeek = presets.find((preset) => preset.id === "next-week"); expect(nextWeek!.whenLabel).toMatch(/Mon/); + const inAMonth = presets.find((preset) => preset.id === "month"); + // The day of month, in any locale ("May 8 9:00", "8. Mai 9:00"). + expect(inAMonth!.whenLabel).toMatch(/\b8\b/); }); it("drops the evening preset once evening is near or past", () => { expect( resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale").map((preset) => preset.id), - ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); + ).toEqual(["hour", "three-hours", "tomorrow", "next-week", "month"]); expect( resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale").map((preset) => preset.id), - ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); + ).toEqual(["hour", "three-hours", "tomorrow", "next-week", "month"]); }); it("puts next week a full week out when today is Monday", () => { @@ -61,6 +70,31 @@ describe("resolveSnoozePresets", () => { expect(nextWeek.getDay()).toBe(1); expect(nextWeek.getDate()).toBe(13); }); + it("wakes in a month on the same day one month out", () => { + const wakeOf = (now: Date) => { + const wake = new Date( + resolveSnoozePresets(now, "locale").find((preset) => preset.id === "month")!.snoozedUntil, + ); + return [wake.getFullYear(), wake.getMonth() + 1, wake.getDate(), wake.getHours()]; + }; + expect(wakeOf(localDate(2026, 9, 29, 10))).toEqual([2026, 10, 29, 9]); + // The last day of a month is not folded into "Tomorrow" any more. + expect(wakeOf(localDate(2026, 9, 30, 22))).toEqual([2026, 10, 30, 9]); + expect(wakeOf(localDate(2026, 12, 15, 10))).toEqual([2027, 1, 15, 9]); + }); + + it("clamps the one-month wake to the last day of a shorter month", () => { + const wakeOf = (now: Date) => { + const wake = new Date( + resolveSnoozePresets(now, "locale").find((preset) => preset.id === "month")!.snoozedUntil, + ); + return [wake.getMonth() + 1, wake.getDate()]; + }; + expect(wakeOf(localDate(2026, 1, 31, 10))).toEqual([2, 28]); + expect(wakeOf(localDate(2028, 1, 31, 10))).toEqual([2, 29]); + expect(wakeOf(localDate(2026, 3, 31, 10))).toEqual([4, 30]); + }); + it("formats preset times with the selected clock preference", () => { const twelveHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "12-hour"); const twentyFourHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour"); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index e7b980279a4b..1ad237810062 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -28,7 +28,9 @@ export function resolveSnoozePresets( whenLabel: preset.id === "next-week" ? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}` - : time, + : preset.id === "month" + ? `${wake.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${time}` + : time, }; }); } diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 104414a9201d..551d798b517f 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -214,7 +214,13 @@ const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; -export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | "next-week"; +export type SnoozePresetId = + | "hour" + | "three-hours" + | "evening" + | "tomorrow" + | "next-week" + | "month"; export interface SnoozePreset { readonly id: SnoozePresetId; @@ -245,12 +251,25 @@ function addSnoozeDays(base: Date, days: number): Date { return next; } +// Same day one month later, clamped to that month's last day: Jan 31 wakes +// on Feb 28 (29 in leap years) instead of overflowing into March. +function addSnoozeMonth(base: Date): Date { + const next = new Date(base); + const day = next.getDate(); + next.setDate(1); + next.setMonth(next.getMonth() + 1); + const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + next.setDate(Math.min(day, lastDay)); + return next; +} + /** * 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". Calendar presets that land on the same * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday - * morning, so only "Tomorrow" is offered. + * morning, so only "Tomorrow" is offered. "In 1 month" is the same day one + * month out, so it never collides with the others. */ export function resolveSnoozePresets(now: Date): ReadonlyArray { const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS)); @@ -299,6 +318,14 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { }); } + const inAMonth = snoozeAtHour(addSnoozeMonth(now), MORNING_HOUR); + presets.push({ + id: "month", + label: "In 1 month", + whenLabel: `${inAMonth.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${snoozeTimeOfDayLabel(inAMonth)}`, + snoozedUntil: inAMonth.toISOString(), + }); + return presets; } diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 793d5a4a3a95..a14341d79357 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -325,6 +325,7 @@ describe("resolveSnoozePresets", () => { "evening", "tomorrow", "next-week", + "month", ]); expect(presets.find((preset) => preset.id === "three-hours")?.snoozedUntil).toBe( localDate(2026, 4, 8, 13).toISOString(), @@ -342,6 +343,7 @@ describe("resolveSnoozePresets", () => { "three-hours", "tomorrow", "next-week", + "month", ]); }); @@ -362,6 +364,7 @@ describe("resolveSnoozePresets", () => { "three-hours", "evening", "tomorrow", + "month", ]); const tomorrow = new Date(presets.find((preset) => preset.id === "tomorrow")!.snoozedUntil); expect(tomorrow.getDay()).toBe(1);