Skip to content
Closed
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
77 changes: 74 additions & 3 deletions apps/web/src/components/Sidebar.snooze.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import { describe, expect, it } from "vite-plus/test";

import { resolveSnoozePresets, snoozeWakeDescription, snoozeWakeLabel } from "./Sidebar.snooze";
import {
defaultCustomSnoozeDateTime,
formatSnoozeDateTimeLocal,
parseCustomSnoozeDateTime,
resolveSnoozePresets,
snoozeWakeDescription,
snoozeWakeLabel,
} from "./Sidebar.snooze";

// Local-time constructor so preset math is timezone-stable in tests.
function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
return new Date(year, month - 1, day, hour, minute, 0, 0);
function localDate(
year: number,
month: number,
day: number,
hour: number,
minute = 0,
second = 0,
): Date {
return new Date(year, month - 1, day, hour, minute, second, 0);
}

function withTimeZone<T>(timeZone: string, run: () => T): T {
const previousTimeZone = process.env.TZ;
process.env.TZ = timeZone;
try {
return run();
} finally {
if (previousTimeZone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = previousTimeZone;
}
}
}

describe("resolveSnoozePresets", () => {
Expand Down Expand Up @@ -64,6 +92,49 @@ describe("resolveSnoozePresets", () => {
});
});

describe("custom snooze time", () => {
it("formats a local wall-clock value without adding a timezone suffix", () => {
expect(formatSnoozeDateTimeLocal(localDate(2026, 4, 8, 9, 5))).toBe("2026-04-08T09:05");
});

it("defaults to at least an hour ahead on a quarter-hour boundary", () => {
const now = new Date(2026, 3, 8, 10, 7, 59, 999);
const value = defaultCustomSnoozeDateTime(now);
const wakeAt = new Date(value);
expect(wakeAt.getTime()).toBeGreaterThanOrEqual(now.getTime() + 60 * 60_000);
expect(wakeAt.getMinutes() % 15).toBe(0);
});

it("keeps the default valid through the repeated hour at the end of DST", () => {
withTimeZone("America/Los_Angeles", () => {
const now = new Date("2024-11-03T01:15:30-07:00");
const value = defaultCustomSnoozeDateTime(now);
const parsed = parseCustomSnoozeDateTime(value, now);
expect(parsed).not.toBeNull();
expect(new Date(parsed!).getTime()).toBeGreaterThanOrEqual(now.getTime() + 60 * 60_000);
});
});

it("converts a future local value to ISO and rejects invalid or past values", () => {
const now = localDate(2026, 4, 8, 10);
const future = "2026-04-08T12:30";
expect(parseCustomSnoozeDateTime(future, now)).toBe(new Date(future).toISOString());
expect(parseCustomSnoozeDateTime("", now)).toBeNull();
expect(parseCustomSnoozeDateTime("not-a-date", now)).toBeNull();
expect(parseCustomSnoozeDateTime("2026-04-08T09:59", now)).toBeNull();
});

it("rejects local wall-clock values normalized across a DST gap", () => {
withTimeZone("America/Los_Angeles", () => {
const now = new Date("2024-03-10T00:00:00-08:00");
expect(parseCustomSnoozeDateTime("2024-03-10T02:30", now)).toBeNull();
expect(parseCustomSnoozeDateTime("2024-03-10T03:30", now)).toBe(
new Date("2024-03-10T03:30:00-07:00").toISOString(),
);
});
});
});

describe("snoozeWakeLabel", () => {
const now = localDate(2026, 4, 8, 10);

Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/components/Sidebar.snooze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const EVENING_HOUR = 18;
const MORNING_HOUR = 9;
const HOUR_MS = 60 * 60 * 1_000;
const DAY_MS = 24 * HOUR_MS;
const CUSTOM_TIME_STEP_MINUTES = 15;
const CUSTOM_TIME_STEP_MS = CUSTOM_TIME_STEP_MINUTES * 60_000;

function atHour(base: Date, hour: number): Date {
const next = new Date(base);
Expand All @@ -44,6 +46,88 @@ function addDays(base: Date, days: number): Date {
return next;
}

/**
* Value for a native datetime-local input. Native date inputs intentionally
* have no timezone component; the value represents the user's wall clock.
*/
export function formatSnoozeDateTimeLocal(date: Date): string {
const pad = (value: number) => String(value).padStart(2, "0");
return [
date.getFullYear(),
"-",
pad(date.getMonth() + 1),
"-",
pad(date.getDate()),
"T",
pad(date.getHours()),
":",
pad(date.getMinutes()),
].join("");
}

/**
* Start custom snoozes one hour out, rounded up to a friendly quarter-hour.
*/
export function defaultCustomSnoozeDateTime(now: Date): string {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
const minimumWakeTime = now.getTime() + HOUR_MS;
const next = new Date(minimumWakeTime);
next.setSeconds(0, 0);
if (next.getTime() < minimumWakeTime) {
next.setTime(next.getTime() + 60_000);
}
next.setMinutes(
Math.ceil(next.getMinutes() / CUSTOM_TIME_STEP_MINUTES) * CUSTOM_TIME_STEP_MINUTES,
);

// datetime-local drops the timezone offset. During the repeated hour at the
// end of DST, formatting the later occurrence and parsing it again can pick
// the earlier occurrence. Advance by friendly quarter-hours until the value
// the form will actually submit remains at least one elapsed hour away.
for (let attempts = 0; attempts < 24 * (60 / CUSTOM_TIME_STEP_MINUTES); attempts += 1) {
const value = formatSnoozeDateTimeLocal(next);
const parsed = parseCustomSnoozeDateTime(value, now);
if (parsed !== null && new Date(parsed).getTime() >= minimumWakeTime) return value;
next.setTime(next.getTime() + CUSTOM_TIME_STEP_MS);
}

// Valid current dates should always find a representable wall-clock value
// within a day. Fail closed instead of looping forever for an invalid Date
// or an unexpected timezone implementation.
return "";
}

/**
* Interpret a datetime-local value in the browser's local timezone and
* return the ISO command payload. Invalid, normalized, and non-future values
* are rejected.
*/
export function parseCustomSnoozeDateTime(value: string, now: Date): string | null {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value.trim());
if (match === null) return null;
const [, yearText, monthText, dayText, hourText, minuteText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const hour = Number(hourText);
const minute = Number(minuteText);
const wakeAt = new Date(year, month - 1, day, hour, minute, 0, 0);
if (Number.isNaN(wakeAt.getTime()) || wakeAt.getTime() <= now.getTime()) return null;

// Date normalizes impossible wall-clock values, including the missing hour
// during a spring-forward transition. Reject that normalization instead of
// silently snoozing until a different time than the user selected.
if (
wakeAt.getFullYear() !== year ||
wakeAt.getMonth() !== month - 1 ||
wakeAt.getDate() !== day ||
wakeAt.getHours() !== hour ||
wakeAt.getMinutes() !== minute
) {
return null;
}
return wakeAt.toISOString();
}

/**
* Presets for "snooze until", computed against local time. "This evening"
* only appears while it is still meaningfully before evening; after that
Expand Down
Loading
Loading