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,
+48 -5
View File
@@ -12,16 +12,49 @@ export interface SleepState {
endsAtMs: number | null;
// Chapter index the end-of-chapter timer was armed at; null otherwise.
chapterIndex: number | null;
// §17 chain: when set on an end-of-chapter arm, leaving the chapter
// does NOT fade — it starts a minutes countdown of this length
// ("end of chapter, then 15 min").
followUpMinutes: number | null;
}
export const SLEEP_OFF: SleepState = { mode: "off", endsAtMs: null, chapterIndex: null };
export const SLEEP_OFF: SleepState = {
mode: "off",
endsAtMs: null,
chapterIndex: null,
followUpMinutes: null,
};
export function armMinutes(nowMs: number, minutes: number): SleepState {
return { mode: "minutes", endsAtMs: nowMs + minutes * 60_000, chapterIndex: null };
return {
mode: "minutes",
endsAtMs: nowMs + minutes * 60_000,
chapterIndex: null,
followUpMinutes: null,
};
}
export function armEndOfChapter(chapterIndex: number): SleepState {
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex };
export function armEndOfChapter(
chapterIndex: number,
followUpMinutes: number | null = null,
): SleepState {
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex, followUpMinutes };
}
// armClockTime (§17): "stop at 23:00" — resolves HH:MM to its NEXT
// occurrence (today if still ahead, otherwise tomorrow) and arms the
// ordinary minutes machinery against that epoch deadline. Returns null
// for unparseable strings; the caller keeps the old state then.
export function armClockTime(nowMs: number, hhmm: string): SleepState | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h > 23 || min > 59) return null;
const target = new Date(nowMs);
target.setHours(h, min, 0, 0);
if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1);
return { mode: "minutes", endsAtMs: target.getTime(), chapterIndex: null, followUpMinutes: null };
}
// sleepDue is true when a minutes-mode deadline has passed.
@@ -66,7 +99,7 @@ export function sleepLabel(state: SleepState, nowMs: number): string {
case "off":
return "Off";
case "end-of-chapter":
return "End of chapter";
return state.followUpMinutes !== null ? `Ch + ${state.followUpMinutes}m` : "End of chapter";
case "fading":
return "Fading";
case "minutes": {
@@ -84,6 +117,9 @@ export interface SleepChoice {
label: string;
mode: "off" | "minutes" | "end-of-chapter";
minutes?: number;
// chain: this end-of-chapter row arms a follow-up minutes countdown
// ("end of chapter, then N min") instead of fading straight away.
chain?: boolean;
}
export const SLEEP_CHOICES: readonly SleepChoice[] = [
@@ -95,4 +131,11 @@ export const SLEEP_CHOICES: readonly SleepChoice[] = [
{ key: "45", label: "45 minutes", mode: "minutes", minutes: 45 },
{ key: "60", label: "60 minutes", mode: "minutes", minutes: 60 },
{ key: "eoch", label: "End of chapter", mode: "end-of-chapter" },
{
key: "eoch15",
label: "End of chapter, then 15 min",
mode: "end-of-chapter",
minutes: 15,
chain: true,
},
];
+55 -3
View File
@@ -25,12 +25,14 @@ import {
import {
SLEEP_CHOICES,
SLEEP_OFF,
armClockTime,
armEndOfChapter,
armMinutes,
fadeVolume,
sleepChapterFired,
sleepDue,
sleepLabel,
sleepRemainingMs,
type SleepChoice,
type SleepState,
} from "./audio-sleep";
@@ -284,6 +286,11 @@ Alpine.data("audioReader", () => ({
sleepArmedChoiceKey: "off",
sleepTicker: null as ReturnType<typeof setInterval> | null,
sleepFadeTimer: null as ReturnType<typeof setInterval> | null,
// §17 clock mode: the popover's time input (armed via chooseSleepClock).
sleepClockTime: "23:00",
// §17 wind-down dim: 0..0.35 black overlay opacity across the final
// 30s of a countdown. Reset by stop/finish.
sleepDim: 0,
// Settings popover (plan §4): header gear → panel. Autoplay-on-open
// is the first option; the panel is structured for more.
@@ -579,7 +586,7 @@ Alpine.data("audioReader", () => ({
this.currentChapterIdx = i;
this.updateMediaSessionMetadata();
// Natural chapter crossing: fire an end-of-chapter sleep.
if (sleepChapterFired(this.sleep, i)) this.beginSleepFade();
if (sleepChapterFired(this.sleep, i)) this.onChapterCrossing(i);
}
return;
}
@@ -637,7 +644,7 @@ Alpine.data("audioReader", () => ({
this.currentChapterIdx = index;
// Manual jump: leaving the armed chapter fires an end-of-chapter
// sleep the same as a natural crossing.
if (sleepChapterFired(this.sleep, index)) this.beginSleepFade();
this.onChapterCrossing(index);
},
setSpeed(rate: number) {
@@ -1139,7 +1146,12 @@ Alpine.data("audioReader", () => ({
}
if (choice.mode === "end-of-chapter") {
this.stopSleepTicker();
this.sleep = armEndOfChapter(this.currentChapterIdx);
// §17 chain: "end of chapter, then N min" — leaving the chapter
// starts the countdown instead of fading straight away.
this.sleep = armEndOfChapter(
this.currentChapterIdx,
choice.chain ? (choice.minutes ?? 15) : null,
);
} else {
this.stopSleepTicker();
this.sleep = armMinutes(Date.now(), choice.minutes ?? 30);
@@ -1148,6 +1160,32 @@ Alpine.data("audioReader", () => ({
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
},
// §17 clock mode: arm against the next occurrence of the popover's
// HH:MM input. Unparseable input keeps the current state.
chooseSleepClock() {
const armed = armClockTime(Date.now(), this.sleepClockTime);
if (!armed) return;
this.stopSleepTicker();
this.sleep = armed;
this.sleepArmedChoiceKey = "clock";
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
this.startSleepTicker();
},
// A chapter crossing fires an end-of-chapter sleep: straight to the
// fade — or (§17 chain arm) convert to the follow-up countdown.
onChapterCrossing(index: number) {
if (!sleepChapterFired(this.sleep, index)) return;
const follow = this.sleep.followUpMinutes;
if (follow !== null) {
this.sleep = armMinutes(Date.now(), follow);
this.startSleepTicker();
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
} else {
this.beginSleepFade();
}
},
startSleepTicker() {
this.stopSleepTicker();
this.sleepTicker = setInterval(() => {
@@ -1159,6 +1197,10 @@ Alpine.data("audioReader", () => ({
return;
}
this.sleepLabelStr = sleepLabel(this.sleep, now);
// §17 wind-down dim: ease toward 35% black across the final 30s.
const remaining = sleepRemainingMs(this.sleep, now);
this.sleepDim =
remaining !== null && remaining < 30_000 ? ((30_000 - remaining) / 30_000) * 0.35 : 0;
}, 1000);
},
@@ -1208,6 +1250,15 @@ Alpine.data("audioReader", () => ({
this.sleep = SLEEP_OFF;
this.sleepArmedChoiceKey = "off";
this.sleepLabelStr = "Off";
this.sleepDim = 0;
// §17: a genuine fire can catch you still awake — one-click undo.
showToast("Sleep timer: playback paused", "info", 8000, {
label: "Resume",
onClick: () => {
const a = this.audio();
if (a) a.play().catch(() => {});
},
});
},
// Manual off / play-abort: undo any partial fade.
@@ -1222,6 +1273,7 @@ Alpine.data("audioReader", () => ({
this.sleep = SLEEP_OFF;
this.sleepArmedChoiceKey = "off";
this.sleepLabelStr = "Off";
this.sleepDim = 0;
},
// ---- settings popover (plan §4) ----
+24 -3
View File
@@ -48,15 +48,23 @@ const getToastConfig = (type: ToastType) => {
};
// Create a toast element
const createToastElement = (message: string, type: ToastType): HTMLElement => {
const createToastElement = (
message: string,
type: ToastType,
action?: { label: string; onClick: () => void },
): HTMLElement => {
const toast = document.createElement("div");
const config = getToastConfig(type);
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
const actionHtml = action
? `<button class="toast-action bg-transparent border border-white/40 text-white cursor-pointer text-xs font-semibold px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 hover:bg-white/10 transition-colors">${toastEscapeHtml(action.label)}</button>`
: "";
toast.innerHTML = `
<span class="text-xl flex-shrink-0">${config.icon}</span>
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
${actionHtml}
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
×
</button>
@@ -68,6 +76,16 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
closeBtn.onclick = () => removeToast(toast);
}
// Action button: run the handler and dismiss (an action consumes the
// toast — leaving it up after acting invites double-fires).
const actionBtn = toast.querySelector(".toast-action") as HTMLElement | null;
if (actionBtn && action) {
actionBtn.onclick = () => {
removeToast(toast);
action.onClick();
};
}
return toast;
};
@@ -90,14 +108,17 @@ const removeToast = (toast: HTMLElement): void => {
}, 300);
};
// Show toast notification
// Show toast notification. `action` (optional): a labeled button for
// one-click follow-ups (e.g. the sleep timer's "Resume"); acting
// dismisses the toast.
const showToast = (
message: string,
type: ToastType,
duration: number = TOAST_DEFAULT_DURATION,
action?: { label: string; onClick: () => void },
): void => {
const container = createToastContainer();
const toast = createToastElement(message, type);
const toast = createToastElement(message, type, action);
container.appendChild(toast);
animateToastIn(toast);