From 6b88ef790d749cf4043381a0efac0a93ba0a1550 Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Fri, 14 Aug 2026 17:55:06 +0200 Subject: [PATCH] feat: add repeat rounds --- src/domain/phase.test.ts | 89 ++++++++++++++++++++++++++ src/domain/phase.ts | 89 ++++++++++++++++++++++++-- src/domain/routine.test.ts | 27 +++++++- src/domain/routine.ts | 48 +++++++++++--- src/pages/configuration.astro | 117 ++++++++++++++++++++++++++++------ src/pages/down.astro | 78 +++++++++++++++++------ src/styles/app.css | 57 +++++++++++++++++ 7 files changed, 446 insertions(+), 59 deletions(-) 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";
-
- Countdown duration +
+ Work @@ -19,17 +19,47 @@ import AppLayout from "../layouts/AppLayout.astro";
- - + +
- - + +
- +
+ Rest (0 means none) + + +
+
+ + +
+
+ + +
+
+
+ +
+ Rounds + 1 +
+ + +
+
+ +

@@ -39,29 +69,43 @@ import AppLayout from "../layouts/AppLayout.astro";