feat(web): sleep timer upgrades — undo toast, chain mode, clock stop, wind-down dim (plan §17)

- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts
  grows an optional action button; acting dismisses).
- Chain: 'End of chapter, then 15 min' — leaving the chapter starts a
  15-min countdown instead of fading (SleepState.followUpMinutes).
- Clock: the popover gains a time input + Arm — armClockTime resolves
  HH:MM to its next occurrence and reuses the minutes machinery.
- Dim: the ticker eases a pointer-events-none overlay toward 35% black
  across a countdown's final 30s; stop/finish resets it.
(Fade-out and countdown labels already existed — §3 built them.)
This commit is contained in:
John O'Keefe committed 2026-10-23 11:06:42 -04:00
1 parent 4769e832c4
commit f8de17723a
6 files changed
+218 -21

No files matched your search

+60 -2
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
SLEEP_CHOICES,
SLEEP_OFF,
armClockTime,
armEndOfChapter,
armMinutes,
fadeVolume,
@@ -77,13 +78,69 @@ describe("formatRemaining / sleepLabel", () => {
it("labels every mode", () => {
expect(sleepLabel(SLEEP_OFF, 0)).toBe("Off");
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
expect(sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null }, 0)).toBe("Fading");
expect(
sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null, followUpMinutes: null }, 0),
).toBe("Fading");
expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55");
});
});
describe("armClockTime (§17 stop-at-clock)", () => {
it("arms a minutes-state deadline at the next occurrence of HH:MM", () => {
// 10:00 local → 23:00 same day.
const tenAm = new Date();
tenAm.setHours(10, 0, 0, 0);
const state = armClockTime(tenAm.getTime(), "23:00");
expect(state?.mode).toBe("minutes");
const expected = new Date(tenAm);
expected.setHours(23, 0, 0, 0);
expect(state?.endsAtMs).toBe(expected.getTime());
// Label is the ordinary countdown machinery (12h59m → 779:00).
expect(sleepLabel(state!, tenAm.getTime() + 60_000)).toBe("779:00");
});
it("wraps to tomorrow when the time already passed", () => {
const noon = new Date();
noon.setHours(12, 0, 0, 0);
const state = armClockTime(noon.getTime(), "09:15");
const expected = new Date(noon);
expected.setDate(expected.getDate() + 1);
expected.setHours(9, 15, 0, 0);
expect(state?.endsAtMs).toBe(expected.getTime());
});
it("rejects unparseable and out-of-range input", () => {
const now = Date.now();
expect(armClockTime(now, "nope")).toBeNull();
expect(armClockTime(now, "24:00")).toBeNull();
expect(armClockTime(now, "10:61")).toBeNull();
});
});
describe("§17 chain: end-of-chapter with a follow-up countdown", () => {
it("arms the follow-up on the state; the chapter fire contract is unchanged", () => {
const state = armEndOfChapter(2, 15);
expect(state.followUpMinutes).toBe(15);
// The sync contract (any chapter change fires) is the same — the
// component converts to armMinutes instead of fading.
expect(sleepChapterFired(state, 3)).toBe(true);
expect(sleepChapterFired(state, 1)).toBe(true);
expect(sleepChapterFired(state, 2)).toBe(false);
});
it("labels the chapter phase with the follow-up length", () => {
expect(sleepLabel(armEndOfChapter(2, 15), 0)).toBe("Ch + 15m");
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
});
it("offers the chain row in the dropdown vocabulary", () => {
const chain = SLEEP_CHOICES.find((c) => c.key === "eoch15");
expect(chain).toMatchObject({ mode: "end-of-chapter", minutes: 15, chain: true });
});
});
describe("SLEEP_CHOICES", () => {
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter", () => {
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter, chain", () => {
expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([
"off",
"5",
@@ -93,6 +150,7 @@ describe("SLEEP_CHOICES", () => {
"45",
"60",
"eoch",
"eoch15",
]);
expect(SLEEP_CHOICES.filter((c) => c.mode === "minutes").map((c) => c.minutes)).toEqual([
5, 10, 15, 30, 45, 60,