* Add overnight pause feature for draft timers Protects players from having their pick timer expire while asleep. Admins configure a nightly window (league-wide or per-user timezone); the timer freezes during that window while autodraft can still fire. Fixes #66 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix TS error: guard getUserDisplayName against undefined user Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
147 lines
5.9 KiB
TypeScript
147 lines
5.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { isInOvernightWindow, getOvernightResumeUTC } from "../overnight-pause";
|
||
|
||
// Helper to build a Date at a specific local time in a timezone.
|
||
// We use a known UTC offset approach: format a UTC date, check what local time
|
||
// it produces, then shift until we hit the desired time.
|
||
function dateAtLocalTime(
|
||
timezone: string,
|
||
year: number,
|
||
month: number, // 1-indexed
|
||
day: number,
|
||
hour: number,
|
||
minute: number
|
||
): Date {
|
||
// Use a binary-search-free approach: construct approximate UTC, then correct
|
||
const approxUtc = Date.UTC(year, month - 1, day, hour, minute);
|
||
const probe = new Date(approxUtc);
|
||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||
timeZone: timezone,
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
});
|
||
const parts = fmt.formatToParts(probe);
|
||
const localHour = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
|
||
const localMin = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
|
||
const h = localHour === 24 ? 0 : localHour;
|
||
const diffMs = ((hour * 60 + minute) - (h * 60 + localMin)) * 60 * 1000;
|
||
return new Date(approxUtc + diffMs);
|
||
}
|
||
|
||
// ─── isInOvernightWindow ──────────────────────────────────────────────────────
|
||
|
||
describe("isInOvernightWindow — midnight-spanning window (23:00–07:00)", () => {
|
||
const tz = "America/New_York";
|
||
const start = "23:00";
|
||
const end = "07:00";
|
||
|
||
it("returns true just after window starts (23:30)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 23, 30))).toBe(true);
|
||
});
|
||
|
||
it("returns true at exact window start (23:00)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 23, 0))).toBe(true);
|
||
});
|
||
|
||
it("returns true past midnight (01:00)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 16, 1, 0))).toBe(true);
|
||
});
|
||
|
||
it("returns true just before window ends (06:59)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 16, 6, 59))).toBe(true);
|
||
});
|
||
|
||
it("returns false exactly at window end (07:00)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 16, 7, 0))).toBe(false);
|
||
});
|
||
|
||
it("returns false in the middle of the day (14:00)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 14, 0))).toBe(false);
|
||
});
|
||
|
||
it("returns false just before window starts (22:59)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 22, 59))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("isInOvernightWindow — same-day window (10:00–18:00)", () => {
|
||
const tz = "America/Chicago";
|
||
const start = "10:00";
|
||
const end = "18:00";
|
||
|
||
it("returns true at 14:00", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 14, 0))).toBe(true);
|
||
});
|
||
|
||
it("returns true at exact start (10:00)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 10, 0))).toBe(true);
|
||
});
|
||
|
||
it("returns false at exact end (18:00)", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 18, 0))).toBe(false);
|
||
});
|
||
|
||
it("returns false at 09:59", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 9, 59))).toBe(false);
|
||
});
|
||
|
||
it("returns false at 20:00", () => {
|
||
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 20, 0))).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("isInOvernightWindow — different IANA timezones", () => {
|
||
it("works for Europe/London at 00:30 in a midnight-spanning window", () => {
|
||
const d = dateAtLocalTime("Europe/London", 2025, 6, 15, 0, 30);
|
||
expect(isInOvernightWindow("Europe/London", "23:00", "07:00", d)).toBe(true);
|
||
});
|
||
|
||
it("works for Asia/Tokyo — 23:30 is in window", () => {
|
||
const d = dateAtLocalTime("Asia/Tokyo", 2025, 6, 15, 23, 30);
|
||
expect(isInOvernightWindow("Asia/Tokyo", "23:00", "07:00", d)).toBe(true);
|
||
});
|
||
|
||
it("works for Asia/Tokyo — 14:00 is not in window", () => {
|
||
const d = dateAtLocalTime("Asia/Tokyo", 2025, 6, 15, 14, 0);
|
||
expect(isInOvernightWindow("Asia/Tokyo", "23:00", "07:00", d)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── getOvernightResumeUTC ────────────────────────────────────────────────────
|
||
|
||
describe("getOvernightResumeUTC", () => {
|
||
it("returns a future date when called during the overnight window", () => {
|
||
const tz = "America/New_York";
|
||
// 2:00 AM ET — inside the 23:00–07:00 window
|
||
const now = dateAtLocalTime(tz, 2025, 6, 16, 2, 0);
|
||
const resume = getOvernightResumeUTC(tz, "07:00", now);
|
||
expect(resume.getTime()).toBeGreaterThan(now.getTime());
|
||
});
|
||
|
||
it("returns a date that represents 07:00 in target timezone", () => {
|
||
const tz = "America/Chicago";
|
||
const now = dateAtLocalTime(tz, 2025, 6, 16, 1, 0);
|
||
const resume = getOvernightResumeUTC(tz, "07:00", now);
|
||
// Verify it shows as 07:00 in Chicago
|
||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||
timeZone: tz,
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
});
|
||
const parts = fmt.formatToParts(resume);
|
||
const h = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
|
||
const m = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
|
||
expect(h).toBe(7);
|
||
expect(m).toBe(0);
|
||
});
|
||
|
||
it("returns tomorrow's end time when current time is after window end (daytime)", () => {
|
||
const tz = "America/New_York";
|
||
// 14:00 ET — outside the window, should return tomorrow 07:00
|
||
const now = dateAtLocalTime(tz, 2025, 6, 16, 14, 0);
|
||
const resume = getOvernightResumeUTC(tz, "07:00", now);
|
||
expect(resume.getTime()).toBeGreaterThan(now.getTime());
|
||
});
|
||
});
|