diff --git a/src/domain/phase.test.ts b/src/domain/phase.test.ts
index e0a82a0..bf5aaf4 100644
--- a/src/domain/phase.test.ts
+++ b/src/domain/phase.test.ts
@@ -10,16 +10,24 @@ describe("phaseAt", () => {
expect(phaseAt(durationMs - 1, routine)).toMatchObject({
status: "running",
+ phaseKind: "work",
displayMs: 1,
+ remainingInPhaseMs: 1,
elapsedMs: durationMs - 1,
+ round: 1,
+ totalRounds: 1,
+ nextPhaseKind: "done",
});
expect(phaseAt(durationMs, routine)).toMatchObject({
status: "done",
+ phaseKind: "done",
displayMs: 0,
elapsedMs: durationMs,
+ round: 1,
});
expect(phaseAt(durationMs + 1, routine)).toMatchObject({
status: "done",
+ phaseKind: "done",
displayMs: 0,
elapsedMs: durationMs + 1,
});
@@ -48,4 +56,85 @@ describe("phaseAt", () => {
elapsedMs: 0,
});
});
+
+ it.each([
+ {
+ name: "Tabata",
+ routine: countdown(20_000, 10_000, 8),
+ phases: [
+ "work",
+ "rest",
+ "work",
+ "rest",
+ "work",
+ "rest",
+ "work",
+ "rest",
+ "work",
+ "rest",
+ "work",
+ "rest",
+ "work",
+ "rest",
+ "work",
+ "rest",
+ ],
+ },
+ {
+ name: "EMOM",
+ routine: countdown(60_000, 0, 10),
+ phases: ["work", "work", "work", "work", "work", "work", "work", "work", "work", "work"],
+ },
+ {
+ name: "Pomodoro",
+ routine: countdown(25 * 60_000, 5 * 60_000, 4),
+ phases: ["work", "rest", "work", "rest", "work", "rest", "work", "rest"],
+ },
+ ])("derives the $name phase sequence from elapsed time", ({ routine, phases }) => {
+ let elapsed = 0;
+ const actual: string[] = [];
+
+ for (let round = 1; round <= routine.rounds; round += 1) {
+ actual.push(phaseAt(elapsed, routine).phaseKind);
+ elapsed += routine.durationMs;
+ if (routine.restMs > 0) {
+ actual.push(phaseAt(elapsed, routine).phaseKind);
+ elapsed += routine.restMs;
+ }
+ }
+
+ expect(actual).toEqual(phases);
+ expect(phaseAt(elapsed, routine)).toMatchObject({ status: "done", phaseKind: "done" });
+ });
+
+ it("changes phase only at exact boundaries", () => {
+ const routine = countdown(20_000, 10_000, 2);
+
+ expect(phaseAt(19_999, routine)).toMatchObject({
+ phaseKind: "work",
+ round: 1,
+ remainingInPhaseMs: 1,
+ });
+ expect(phaseAt(20_000, routine)).toMatchObject({
+ phaseKind: "rest",
+ round: 1,
+ remainingInPhaseMs: 10_000,
+ });
+ expect(phaseAt(29_999, routine)).toMatchObject({
+ phaseKind: "rest",
+ round: 1,
+ remainingInPhaseMs: 1,
+ });
+ expect(phaseAt(30_000, routine)).toMatchObject({
+ phaseKind: "work",
+ round: 2,
+ remainingInPhaseMs: 20_000,
+ });
+ expect(phaseAt(59_999, routine)).toMatchObject({
+ phaseKind: "rest",
+ round: 2,
+ remainingInPhaseMs: 1,
+ });
+ expect(phaseAt(60_000, routine)).toMatchObject({ status: "done", phaseKind: "done", round: 2 });
+ });
});
diff --git a/src/domain/phase.ts b/src/domain/phase.ts
index dd99381..74b6f8d 100644
--- a/src/domain/phase.ts
+++ b/src/domain/phase.ts
@@ -1,25 +1,100 @@
import { MAX_DURATION_MS, type Routine } from "./routine.ts";
+export type PhaseKind = "prepare" | "work" | "rest" | "done";
export type TimerStatus = "running" | "done";
export type TimerState = {
status: TimerStatus;
+ phaseKind: PhaseKind;
+ remainingInPhaseMs: number;
+ elapsedInPhaseMs: number;
+ phaseProgress: number;
+ round: number;
+ totalRounds: number;
+ nextPhaseKind: PhaseKind | null;
+ totalElapsedMs: number;
+ /** Compatibility aliases used by the existing display and clock screens. */
displayMs: number;
elapsedMs: number;
};
+const doneState = (
+ elapsedMs: number,
+ totalRounds: number,
+ totalElapsedMs: number,
+ displayMs = 0,
+): TimerState => ({
+ status: "done",
+ phaseKind: "done",
+ remainingInPhaseMs: 0,
+ elapsedInPhaseMs: 0,
+ phaseProgress: 1,
+ round: totalRounds,
+ totalRounds,
+ nextPhaseKind: null,
+ totalElapsedMs,
+ displayMs,
+ elapsedMs,
+});
+
/**
* Derive display state from elapsed time. No ticking state lives here, so a delayed frame still lands
- * on the correct value and exact countdown zero can end the Routine without rendering a negative time.
+ * on the correct value and exact phase boundaries never drift.
*/
export const phaseAt = (rawElapsedMs: number, routine: Routine): TimerState => {
- const elapsedMs = Math.max(0, rawElapsedMs);
+ const elapsedMs = Number.isFinite(rawElapsedMs) ? Math.max(0, rawElapsedMs) : 0;
- if (routine.direction === "down") {
- const displayMs = Math.max(0, routine.durationMs - elapsedMs);
- return { status: displayMs === 0 ? "done" : "running", displayMs, elapsedMs };
+ if (routine.direction === "up") {
+ const displayMs = Math.min(MAX_DURATION_MS, elapsedMs);
+ return displayMs === MAX_DURATION_MS
+ ? doneState(elapsedMs, 1, MAX_DURATION_MS, MAX_DURATION_MS)
+ : {
+ status: "running",
+ phaseKind: "work",
+ remainingInPhaseMs: elapsedMs,
+ elapsedInPhaseMs: elapsedMs,
+ phaseProgress: 0,
+ round: 1,
+ totalRounds: 1,
+ nextPhaseKind: null,
+ totalElapsedMs: elapsedMs,
+ displayMs,
+ elapsedMs,
+ };
}
- const displayMs = Math.min(MAX_DURATION_MS, elapsedMs);
- return { status: displayMs === MAX_DURATION_MS ? "done" : "running", displayMs, elapsedMs };
+ const roundLengthMs = routine.durationMs + routine.restMs;
+ const totalDurationMs = roundLengthMs * routine.rounds;
+ if (elapsedMs >= totalDurationMs) return doneState(elapsedMs, routine.rounds, totalDurationMs);
+
+ const round = Math.floor(elapsedMs / roundLengthMs) + 1;
+ const elapsedInRoundMs = elapsedMs % roundLengthMs;
+ const inRest = routine.restMs > 0 && elapsedInRoundMs >= routine.durationMs;
+ const phaseKind: PhaseKind = inRest ? "rest" : "work";
+ const phaseDurationMs = inRest ? routine.restMs : routine.durationMs;
+ const elapsedInPhaseMs = inRest ? elapsedInRoundMs - routine.durationMs : elapsedInRoundMs;
+ const remainingInPhaseMs = phaseDurationMs - elapsedInPhaseMs;
+ const nextPhaseKind: PhaseKind = inRest
+ ? round < routine.rounds
+ ? "work"
+ : "done"
+ : routine.restMs > 0
+ ? "rest"
+ : round < routine.rounds
+ ? "work"
+ : "done";
+
+ return {
+ status: "running",
+ phaseKind,
+ remainingInPhaseMs,
+ elapsedInPhaseMs,
+ phaseProgress: elapsedInPhaseMs / phaseDurationMs,
+ round,
+ totalRounds: routine.rounds,
+ nextPhaseKind,
+ totalElapsedMs: elapsedMs,
+ displayMs: remainingInPhaseMs,
+ elapsedMs,
+ };
};
diff --git a/src/domain/routine.test.ts b/src/domain/routine.test.ts
index b452350..49172b0 100644
--- a/src/domain/routine.test.ts
+++ b/src/domain/routine.test.ts
@@ -1,16 +1,25 @@
import { describe, expect, it } from "vitest";
import {
+ clampRounds,
countdown,
DEFAULT_DURATION_MS,
durationFromParts,
MAX_DURATION_MS,
+ MAX_ROUNDS,
MIN_DURATION_MS,
+ MIN_ROUNDS,
stepDuration,
+ stepRounds,
} from "./routine.ts";
describe("countdown duration", () => {
it("defaults to five minutes", () => {
- expect(countdown()).toEqual({ direction: "down", durationMs: DEFAULT_DURATION_MS });
+ expect(countdown()).toEqual({
+ direction: "down",
+ durationMs: DEFAULT_DURATION_MS,
+ restMs: 0,
+ rounds: 1,
+ });
});
it("carries seconds into minutes", () => {
@@ -38,4 +47,20 @@ describe("countdown duration", () => {
expect(durationFromParts(99, 60)).toBe(MAX_DURATION_MS);
expect(durationFromParts(Number.NaN, Number.POSITIVE_INFINITY)).toBe(MIN_DURATION_MS);
});
+
+ it("allows rest to be disabled at zero", () => {
+ expect(countdown(20_000, 0, 8)).toMatchObject({ restMs: 0, rounds: 8 });
+ expect(durationFromParts(0, 0, true)).toBe(0);
+ expect(stepDuration(0, "seconds", -1, true)).toBe(0);
+ expect(stepDuration(1000, "seconds", -1, true)).toBe(0);
+ });
+
+ it("clamps rounds and steps them one at a time", () => {
+ expect(clampRounds(0)).toBe(MIN_ROUNDS);
+ expect(clampRounds(MAX_ROUNDS + 1)).toBe(MAX_ROUNDS);
+ expect(clampRounds(Number.NaN)).toBe(MIN_ROUNDS);
+ expect(stepRounds(1, -1)).toBe(MIN_ROUNDS);
+ expect(stepRounds(MAX_ROUNDS, 1)).toBe(MAX_ROUNDS);
+ expect(stepRounds(8, 1)).toBe(9);
+ });
});
diff --git a/src/domain/routine.ts b/src/domain/routine.ts
index 07d3912..0d0727e 100644
--- a/src/domain/routine.ts
+++ b/src/domain/routine.ts
@@ -1,9 +1,11 @@
-/** A Routine chooses count direction and, for countdowns, its configured duration. */
+/** A Routine chooses count direction and, for countdowns, its configured phases and rounds. */
export type CountDirection = "up" | "down";
export type Routine = {
direction: CountDirection;
durationMs: number;
+ restMs: number;
+ rounds: number;
};
export type DurationUnit = "minutes" | "seconds";
@@ -11,25 +13,51 @@ export type DurationUnit = "minutes" | "seconds";
export const MIN_DURATION_MS = 1_000;
export const MAX_DURATION_MS = 99 * 60_000 + 59_000;
export const DEFAULT_DURATION_MS = 5 * 60_000;
+export const MIN_ROUNDS = 1;
+export const MAX_ROUNDS = 99;
-export const chrono = (): Routine => ({ direction: "up", durationMs: 0 });
-
-export const clampDuration = (durationMs: number): number => {
+const clampPhaseDuration = (durationMs: number, allowZero: boolean): number => {
const rounded = Number.isFinite(durationMs) ? Math.round(durationMs) : MIN_DURATION_MS;
- return Math.min(MAX_DURATION_MS, Math.max(MIN_DURATION_MS, rounded));
+ const minimum = allowZero ? 0 : MIN_DURATION_MS;
+ return Math.min(MAX_DURATION_MS, Math.max(minimum, rounded));
};
-export const countdown = (durationMs = DEFAULT_DURATION_MS): Routine => ({
+export const clampDuration = (durationMs: number): number => clampPhaseDuration(durationMs, false);
+export const clampRestDuration = (durationMs: number): number =>
+ clampPhaseDuration(durationMs, true);
+
+export const clampRounds = (rounds: number): number => {
+ const whole = Number.isFinite(rounds) ? Math.trunc(rounds) : MIN_ROUNDS;
+ return Math.min(MAX_ROUNDS, Math.max(MIN_ROUNDS, whole));
+};
+
+export const chrono = (): Routine => ({ direction: "up", durationMs: 0, restMs: 0, rounds: 1 });
+
+export const countdown = (
+ durationMs = DEFAULT_DURATION_MS,
+ restMs = 0,
+ rounds = MIN_ROUNDS,
+): Routine => ({
direction: "down",
durationMs: clampDuration(durationMs),
+ restMs: clampRestDuration(restMs),
+ rounds: clampRounds(rounds),
});
/** Parse URL parts with the same carry and bounds as steppers. */
-export const durationFromParts = (minutes: number, seconds: number): number => {
+export const durationFromParts = (minutes: number, seconds: number, allowZero = false): number => {
const whole = (value: number) => (Number.isFinite(value) ? Math.trunc(value) : 0);
- return clampDuration((whole(minutes) * 60 + whole(seconds)) * 1000);
+ return clampPhaseDuration((whole(minutes) * 60 + whole(seconds)) * 1000, allowZero);
};
/** Step one labelled unit and carry between seconds and minutes naturally. */
-export const stepDuration = (durationMs: number, unit: DurationUnit, amount: number): number =>
- clampDuration(durationMs + (unit === "minutes" ? 60_000 : 1_000) * amount);
+export const stepDuration = (
+ durationMs: number,
+ unit: DurationUnit,
+ amount: number,
+ allowZero = false,
+): number =>
+ clampPhaseDuration(durationMs + (unit === "minutes" ? 60_000 : 1_000) * amount, allowZero);
+
+/** Step rounds by one and keep them within the displayable range. */
+export const stepRounds = (rounds: number, amount: number): number => clampRounds(rounds + amount);
diff --git a/src/pages/configuration.astro b/src/pages/configuration.astro
index 98cf10c..04a4f89 100644
--- a/src/pages/configuration.astro
+++ b/src/pages/configuration.astro
@@ -8,8 +8,8 @@ import AppLayout from "../layouts/AppLayout.astro";