Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions apps/web/src/components/Sidebar.snooze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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);
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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");
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/Sidebar.snooze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});
}
Expand Down
31 changes: 29 additions & 2 deletions packages/client-runtime/src/state/threadSettled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SnoozePreset> {
const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS));
Expand Down Expand Up @@ -299,6 +318,14 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray<SnoozePreset> {
});
}

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

Expand Down
3 changes: 3 additions & 0 deletions packages/client-runtime/src/state/threadSnoozed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -342,6 +343,7 @@ describe("resolveSnoozePresets", () => {
"three-hours",
"tomorrow",
"next-week",
"month",
]);
});

Expand All @@ -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);
Expand Down
Loading