Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/domain/phase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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 });
});
});
89 changes: 82 additions & 7 deletions src/domain/phase.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
27 changes: 26 additions & 1 deletion src/domain/routine.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
48 changes: 38 additions & 10 deletions src/domain/routine.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,63 @@
/** 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";

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);
Loading