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:
1 parent
4769e832c4
commit
f8de17723a
6 files changed
+218
-21
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user