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);